mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user