From 944c97fa043626fdda03ead776cad27baf10ce35 Mon Sep 17 00:00:00 2001 From: CypherPoet <46851636+CypherPoet@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:01:07 -0500 Subject: [PATCH] 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. --- skill/scripts/context.mjs | 15 ++++++++ tests/context.test.mjs | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index adf03e0d4..3236b2e9c 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -8,6 +8,8 @@ * 1. Active project root, if PRODUCT.md or DESIGN.md is there * 2. Active project .agents/context/ then docs/ * 3. Monorepo root context, using the same order, as a per-file fallback + * (a repo counts as a monorepo when a package manager declares workspaces, + * or `.impeccable/config.json` declares `contextRoots`) * 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 @@ -525,12 +527,25 @@ function workspacePatternMatchesRel(pattern, relSegments) { function readWorkspacePatterns(repoRoot) { return [ + ...readImpeccableContextRoots(repoRoot), ...readPackageWorkspaces(repoRoot), ...readPnpmWorkspaces(repoRoot), ...readLernaWorkspaces(repoRoot), ].filter(Boolean); } +function readImpeccableContextRoots(repoRoot) { + const patterns = []; + for (const name of ['config.json', 'config.local.json']) { + const cfg = readJson(path.join(repoRoot, '.impeccable', name)); + if (!Array.isArray(cfg?.contextRoots)) continue; + for (const entry of cfg.contextRoots) { + if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); + } + } + return patterns; +} + function readPackageWorkspaces(repoRoot) { const pkg = readJson(path.join(repoRoot, 'package.json')); const workspaces = pkg?.workspaces; diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 3ba18dbd3..4afa45621 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -698,6 +698,81 @@ describe('loadContext (monorepo project context)', () => { }); }); +describe('loadContext (impeccable contextRoots config)', () => { + function writeSkinsConfig(extra = {}) { + write('.impeccable/config.json', JSON.stringify({ contextRoots: ['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 contextRoots with config.local.json', () => { + write('.impeccable/config.json', JSON.stringify({ contextRoots: ['docs/design/skins/*'] }, null, 2)); + write('.impeccable/config.local.json', JSON.stringify({ contextRoots: ['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 contextRoots 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({ contextRoots: ['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']); + }); +}); + describe('loadContext (IMPECCABLE_CONTEXT_DIR escape hatch)', () => { it('reads from the override path when defaults are empty', () => { write('design/PRODUCT.md', '# overridden product\n');