Allow context roots to be declared in .impeccable/config.json (decoupled from package managers) (#307)

* Allow context roots to be declared in .impeccable/config.json

Monorepo detection previously read workspace roots only from package
managers (package.json workspaces, pnpm-workspace.yaml, lerna.json),
coupling "where design context lives" to the dependency graph. Add a
`contextRoots` glob list to .impeccable/config.json / config.local.json
so non-JS repos -- and design-context boundaries that don't match
packages -- can declare nested PRODUCT.md/DESIGN.md roots directly.

The new source is folded into readWorkspacePatterns(), so detection,
project resolution, and the app picker pick it up unchanged. Negation
and config.local.json extension work for free.

* Define projectRoots composition with package workspaces

Address review feedback on #307:

- Rename the config key contextRoots -> projectRoots: the globs establish
  project boundaries and app-picker targets, not just where context files
  live.
- Make cross-source precedence explicit: a path matched by any impeccable
  pattern, positive or negated, is governed by the impeccable group alone;
  package-manager patterns fill in the paths it does not match, and `!`
  negations apply only within their own source. readWorkspacePatterns()
  becomes readProjectPatternGroups() / readProjectPatterns(), with package
  workspaces as one discovery source.
- Drop app-picker candidates that would resolve elsewhere: a package
  workspace subsumed by a broader impeccable boundary is no longer listed,
  since choosing it would silently resolve to that boundary.
- Add five composition tests and document the key in the config and
  context reference pages (path relativity, glob and negation syntax,
  shared/local merge, precedence).
This commit is contained in:
CypherPoet
2026-07-20 18:44:43 -07:00
committed by GitHub
parent 51b470f903
commit a6957e5d4b
4 changed files with 245 additions and 20 deletions
+62 -19
View File
@@ -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) {