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
+162
View File
@@ -769,6 +769,168 @@ describe('loadContext (monorepo project context)', () => {
});
});
describe('loadContext (impeccable projectRoots config)', () => {
function writeSkinsConfig(extra = {}) {
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'], ...extra }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('DESIGN.md', '# Root design\n');
}
it('treats a config-declared context root as a monorepo with no package-manager files', () => {
writeSkinsConfig();
write('docs/design/skins/neon-seoul/DESIGN.md', '# Neon Seoul design\n');
const ctx = loadContext(scratch, { targetPath: 'docs/design/skins/neon-seoul' });
assert.equal(ctx.isMonorepo, true);
assert.equal(ctx.projectRoot, path.join(scratch, 'docs', 'design', 'skins', 'neon-seoul'));
assert.equal(ctx.repoRoot, scratch);
// The skin uses its own DESIGN.md and inherits the root PRODUCT.md per file.
assert.match(ctx.design, /Neon Seoul design/);
assert.match(ctx.product, /Root product/);
assert.equal(ctx.designPath, path.join('docs', 'design', 'skins', 'neon-seoul', 'DESIGN.md'));
assert.equal(ctx.productPath, 'PRODUCT.md');
});
it('resolves a config-declared child from cwd inside the folder', () => {
writeSkinsConfig();
write('docs/design/skins/marble/DESIGN.md', '# Marble design\n');
const skinDir = path.join(scratch, 'docs', 'design', 'skins', 'marble');
const ctx = loadContext(skinDir);
assert.equal(ctx.isMonorepo, true);
assert.equal(ctx.projectRoot, skinDir);
assert.match(ctx.design, /Marble design/);
assert.match(ctx.product, /Root product/);
});
it('extends shared projectRoots with config.local.json', () => {
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'] }, null, 2));
write('.impeccable/config.local.json', JSON.stringify({ projectRoots: ['experiments/*'] }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('DESIGN.md', '# Root design\n');
write('experiments/wip/DESIGN.md', '# WIP design\n');
const ctx = loadContext(scratch, { targetPath: 'experiments/wip' });
assert.equal(ctx.projectRoot, path.join(scratch, 'experiments', 'wip'));
assert.match(ctx.design, /WIP design/);
assert.match(ctx.product, /Root product/);
});
it('does not treat an .impeccable config without projectRoots as a monorepo', () => {
write('.impeccable/config.json', JSON.stringify({ hook: { consent: 'accepted' } }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('docs/design/skins/marble/DESIGN.md', '# Marble design\n');
const ctx = loadContext(scratch, { targetPath: 'docs/design/skins/marble' });
assert.equal(ctx.isMonorepo, false);
});
it('asks for app selection from a config-declared monorepo root', () => {
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'] }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('DESIGN.md', '# Root design\n');
write('docs/design/skins/neon-seoul/DESIGN.md', '# Neon Seoul\n');
write('docs/design/skins/marble/DESIGN.md', '# Marble\n');
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
});
assert.equal(res.status, 0);
const selection = parseTargetSelection(res.stdout);
const paths = selection.targetCandidates.map((candidate) => candidate.path).sort();
assert.deepEqual(paths, ['docs/design/skins/marble', 'docs/design/skins/neon-seoul']);
});
// Composition with package-manager workspaces: a path matched by any
// projectRoots pattern (positive or negated) is governed by the impeccable
// config alone; package-manager patterns fill in the paths it does not match.
describe('composition with package-manager workspaces', () => {
function selectionPaths() {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
const selection = parseTargetSelection(res.stdout);
return selection.targetCandidates.map((candidate) => candidate.path).sort();
}
it('lets an impeccable negation exclude a package-manager workspace', () => {
write('package.json', JSON.stringify({ private: true, workspaces: ['apps/*'] }, null, 2));
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['!apps/internal'] }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
write('apps/internal/PRODUCT.md', '# Internal product\n');
const ctx = loadContext(scratch, { targetPath: 'apps/internal' });
assert.equal(ctx.isMonorepo, true);
assert.equal(ctx.projectRoot, scratch);
assert.match(ctx.product, /Root product/);
assert.deepEqual(selectionPaths(), ['apps/dashboard']);
});
it('keeps a package-manager negation scoped to its own source', () => {
write('package.json', JSON.stringify({
private: true,
workspaces: ['apps/*', '!docs/design/skins/marble'],
}, null, 2));
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'] }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
write('docs/design/skins/marble/DESIGN.md', '# Marble design\n');
const ctx = loadContext(scratch, { targetPath: 'docs/design/skins/marble' });
assert.equal(ctx.projectRoot, path.join(scratch, 'docs', 'design', 'skins', 'marble'));
assert.match(ctx.design, /Marble design/);
assert.deepEqual(selectionPaths(), ['apps/dashboard', 'docs/design/skins/marble']);
});
it('gives a broad impeccable pattern the boundary over a deeper package workspace', () => {
write('package.json', JSON.stringify({ private: true, workspaces: ['apps/web/packages/ui'] }, null, 2));
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['apps/*'] }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('apps/web/PRODUCT.md', '# Web product\n');
write('apps/web/packages/ui/PRODUCT.md', '# UI product\n');
write('apps/web/packages/ui/src/Button.jsx', 'export default null;\n');
const ctx = loadContext(scratch, { targetPath: 'apps/web/packages/ui/src/Button.jsx' });
assert.equal(ctx.projectRoot, path.join(scratch, 'apps', 'web'));
assert.match(ctx.product, /Web product/);
// The subsumed package workspace must not appear as its own pick:
// choosing it would silently resolve to apps/web.
assert.deepEqual(selectionPaths(), ['apps/web']);
});
it('falls through to package workspaces for paths impeccable does not match', () => {
write('package.json', JSON.stringify({ private: true, workspaces: ['apps/*'] }, null, 2));
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['docs/design/skins/*'] }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
write('docs/design/skins/marble/DESIGN.md', '# Marble design\n');
const ctx = loadContext(scratch, { targetPath: 'apps/dashboard' });
assert.equal(ctx.projectRoot, path.join(scratch, 'apps', 'dashboard'));
assert.match(ctx.product, /Dashboard product/);
});
it('resolves other workspaces normally when impeccable config only negates', () => {
write('package.json', JSON.stringify({ private: true, workspaces: ['apps/*'] }, null, 2));
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['!apps/internal'] }, null, 2));
write('PRODUCT.md', '# Root product\n');
write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
write('apps/internal/PRODUCT.md', '# Internal product\n');
const ctx = loadContext(scratch, { targetPath: 'apps/dashboard' });
assert.equal(ctx.isMonorepo, true);
assert.equal(ctx.projectRoot, path.join(scratch, 'apps', 'dashboard'));
assert.match(ctx.product, /Dashboard product/);
});
});
});
describe('loadContext (IMPECCABLE_CONTEXT_DIR escape hatch)', () => {
it('reads from the override path when defaults are empty', () => {
write('design/PRODUCT.md', '# overridden product\n');