From 4d849eb75f216109ea7053ed21530a11fafcc786 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:45:13 +0000 Subject: [PATCH] Sync generated provider output --- .agents/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .claude/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .cursor/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .gemini/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .github/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .kiro/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .../skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .pi/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .qoder/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .../skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .../skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .trae/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- .vibe/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- plugin/skills/impeccable/scripts/context.mjs | 81 ++++++++++++++----- 14 files changed, 868 insertions(+), 266 deletions(-) diff --git a/.agents/skills/impeccable/scripts/context.mjs b/.agents/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.agents/skills/impeccable/scripts/context.mjs +++ b/.agents/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.claude/skills/impeccable/scripts/context.mjs b/.claude/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.claude/skills/impeccable/scripts/context.mjs +++ b/.claude/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.cursor/skills/impeccable/scripts/context.mjs b/.cursor/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.cursor/skills/impeccable/scripts/context.mjs +++ b/.cursor/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.gemini/skills/impeccable/scripts/context.mjs b/.gemini/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.gemini/skills/impeccable/scripts/context.mjs +++ b/.gemini/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.github/skills/impeccable/scripts/context.mjs b/.github/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.github/skills/impeccable/scripts/context.mjs +++ b/.github/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.kiro/skills/impeccable/scripts/context.mjs b/.kiro/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.kiro/skills/impeccable/scripts/context.mjs +++ b/.kiro/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.opencode/skills/impeccable/scripts/context.mjs b/.opencode/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.opencode/skills/impeccable/scripts/context.mjs +++ b/.opencode/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.pi/skills/impeccable/scripts/context.mjs b/.pi/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.pi/skills/impeccable/scripts/context.mjs +++ b/.pi/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.qoder/skills/impeccable/scripts/context.mjs b/.qoder/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.qoder/skills/impeccable/scripts/context.mjs +++ b/.qoder/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.rovodev/skills/impeccable/scripts/context.mjs b/.rovodev/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.rovodev/skills/impeccable/scripts/context.mjs +++ b/.rovodev/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.trae-cn/skills/impeccable/scripts/context.mjs b/.trae-cn/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.trae-cn/skills/impeccable/scripts/context.mjs +++ b/.trae-cn/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.trae/skills/impeccable/scripts/context.mjs b/.trae/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.trae/skills/impeccable/scripts/context.mjs +++ b/.trae/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/.vibe/skills/impeccable/scripts/context.mjs b/.vibe/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/.vibe/skills/impeccable/scripts/context.mjs +++ b/.vibe/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) { diff --git a/plugin/skills/impeccable/scripts/context.mjs b/plugin/skills/impeccable/scripts/context.mjs index 9715525db..6bb5f6d03 100644 --- a/plugin/skills/impeccable/scripts/context.mjs +++ b/plugin/skills/impeccable/scripts/context.mjs @@ -13,7 +13,9 @@ * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback - * whenever the active project is nested below it + * whenever the active project is nested below it (a repo counts as a + * monorepo when a package manager declares workspaces, or + * `.impeccable/config.json` declares `projectRoots`) * 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 @@ -242,7 +244,7 @@ function findMonorepoRoot(startDir) { } function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } @@ -267,10 +269,12 @@ function hasFallbackWorkspaceChildren(dir) { function discoverTargetCandidates(repoRoot) { const roots = new Map(); - const patterns = readWorkspacePatterns(repoRoot); - for (const pattern of patterns) { - for (const root of discoverRootsForPattern(repoRoot, pattern)) { - roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + const patternGroups = readProjectPatternGroups(repoRoot); + for (const patterns of patternGroups) { + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { @@ -291,10 +295,7 @@ function discoverTargetCandidates(repoRoot) { } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) - // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot - // sends an excluded package back to the repo root, so an excluded folder must not appear as a - // selectable target — choosing it would silently resolve to the root instead. - .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); @@ -450,15 +451,13 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); - const patterns = readWorkspacePatterns(repoRoot); - const excluded = isExcludedByWorkspacePattern(relSegments, patterns); - if (!excluded) { + for (const patterns of readProjectPatternGroups(repoRoot)) { + if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } - if (excluded) return repoRoot; if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) @@ -470,6 +469,25 @@ function resolveWorkspaceProjectRoot(repoRoot, targetDir) { return repoRoot; } +// A discovered folder is only selectable when picking it would resolve back to +// itself. Impeccable `projectRoots` patterns govern every path they match: +// a negation drops the candidate (resolveWorkspaceProjectRoot would send it to +// the repo root), and a positive match with a different boundary drops it too, +// because the boundary root is already its own candidate and choosing the +// deeper folder would silently resolve there. Paths the Impeccable group does +// not match fall through to the package-manager negations, which is the +// pre-existing behavior for package workspaces and marker-dir fallbacks. +function isSelectableCandidate(repoRoot, rel, patternGroups) { + const relSegments = rel.split('/').filter(Boolean); + const [impeccablePatterns, packagePatterns] = patternGroups; + if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; + for (const pattern of impeccablePatterns) { + const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); + } + return !isExcludedByWorkspacePattern(relSegments, packagePatterns); +} + function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); @@ -558,12 +576,37 @@ function workspacePatternMatchesRel(pattern, relSegments) { return true; } -function readWorkspacePatterns(repoRoot) { +// Project boundaries come from two sources, in precedence order: explicit +// `projectRoots` globs in .impeccable config, then package-manager workspace +// declarations. A path matched by any Impeccable pattern — positive or +// negated — is governed by the Impeccable group alone; package-manager +// patterns only apply to paths the Impeccable group does not match. Within a +// group, negations win over positives. +function readProjectPatternGroups(repoRoot) { return [ - ...readPackageWorkspaces(repoRoot), - ...readPnpmWorkspaces(repoRoot), - ...readLernaWorkspaces(repoRoot), - ].filter(Boolean); + readImpeccableProjectRoots(repoRoot), + [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean), + ]; +} + +function readProjectPatterns(repoRoot) { + return readProjectPatternGroups(repoRoot).flat(); +} + +function readImpeccableProjectRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.projectRoots)) continue; + for (const entry of cfg.projectRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; } function readPackageWorkspaces(repoRoot) {