From dca8f1ca6f860784ca9da26e76ded0b827c67ed7 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 14 Aug 2026 23:41:29 +0500 Subject: [PATCH 1/5] Fix: inherit the monorepo root's DESIGN.md in detect design-system rules (#570) findDesignRoot stopped at the first package.json boundary, so every design-system rule silently abstained for files inside monorepo workspaces. The walk now continues past a workspace boundary to the monorepo root that owns it, recognized the same way context.mjs does (declared workspace globs, or a marker file beside apps/ or packages/ children). A nested repo with its own .git, a workspace-owned DESIGN.md, and non-monorepo projects keep their existing behavior. Written with AI assistance (Cursor); reviewed by maintainer. Co-authored-by: Cursor --- cli/engine/design-system.mjs | 72 ++++++++- scripts/test-suites.mjs | 1 + tests/detect-cli-design-monorepo.test.mjs | 181 ++++++++++++++++++++++ 3 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 tests/detect-cli-design-monorepo.test.mjs diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index 5c9a949e6..1f07d5815 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs']; // CLI can't import (separate tree). `.git` and `package.json` are the common // boundaries; `.impeccable` is our own project marker. const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable']; +// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared +// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`) +// or a marker file beside apps/ or packages/ children. +const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json']; +const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages']; const COLOR_CHANNEL_TOLERANCE = 6; // Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the // difference between a documented shadow and drift), so shadow matching cannot @@ -581,24 +586,77 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) { // - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the // design root — that's where the rules live. // - A directory carrying a project marker (.git / package.json / .impeccable) -// but no DESIGN.md is a project BOUNDARY: the walk stops with no design -// system, so a sibling project never inherits a parent's or cwd's rules. +// but no DESIGN.md is a project BOUNDARY. When that boundary is a monorepo +// workspace (owned by a workspace-declaring root above it, recognized the +// same way context.mjs does), the workspace inherits the monorepo root's +// DESIGN.md; a nested separate repository (.git with no workspace +// declaration) still inherits nothing (issue #570). // - Reaching the home directory / filesystem root with neither means no // design system at all — never process.cwd()'s. // // Returns { dir, hasDesign } for the stopping directory, or null when the walk // runs out. This is the fix for cross-project contamination. +function readWorkspacePatterns(dir) { + const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces; + const patterns = Array.isArray(workspaces) ? [...workspaces] + : Array.isArray(workspaces?.packages) ? [...workspaces.packages] : []; + try { + let inPackages = false; + for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); + if (flow) { + patterns.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)); + break; + } + if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; } + if (!inPackages) continue; + const item = trimmed.match(/^-\s*(.+)$/); + if (item) patterns.push(item[1].trim().replace(/^['"]|['"]$/g, '')); + else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break; + } + } catch { /* no pnpm-workspace.yaml */ } + return patterns; +} + +function isMonorepoRoot(dir) { + if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true; + if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false; + return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => { + try { + return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory()); + } catch { + return false; + } + }); +} + export function findDesignRoot(startDir) { let dir = path.resolve(startDir); const homeDir = path.resolve(os.homedir()); + let boundary = null; while (true) { - if (resolveDesignMdPath(dir)) return { dir, hasDesign: true }; - if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) { - return { dir, hasDesign: false }; + if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true }; + if (boundary) { + // Past the boundary the walk only looks for the monorepo root that owns + // the workspace. Monorepo-root before .git, same order as context.mjs: + // a workspace root carrying its own .git is still recognized, while a + // .git that declares no workspaces is a separate repository and stops + // the walk with nothing inherited. The home directory is never an + // owning root, same as context.mjs's findMonorepoRoot, which stops at + // homeDir before its monorepo check. + if (dir !== homeDir && isMonorepoRoot(dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) }; + if (fs.existsSync(path.join(dir, '.git'))) return boundary; + } else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) { + boundary = { dir, hasDesign: false }; + // A boundary that is itself a monorepo root, or a separate repository + // with its own .git, inherits nothing from above. + if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary; } - if (dir === homeDir) return null; + if (dir === homeDir) return boundary; const parent = path.dirname(dir); - if (parent === dir) return null; + if (parent === dir) return boundary; dir = parent; } } diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 4fba4df6d..1c1f64c58 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -107,6 +107,7 @@ export const SUITES = { 'tests/detect-antipatterns-fixtures.test.mjs', 'tests/detect-antipatterns-browser.test.mjs', 'tests/detect-cli-design-contamination.test.mjs', + 'tests/detect-cli-design-monorepo.test.mjs', 'tests/detect-cli-stdin-dispatch.test.mjs', ], }, diff --git a/tests/detect-cli-design-monorepo.test.mjs b/tests/detect-cli-design-monorepo.test.mjs new file mode 100644 index 000000000..c7767b6b4 --- /dev/null +++ b/tests/detect-cli-design-monorepo.test.mjs @@ -0,0 +1,181 @@ +/** + * Regression for issue #570: design-system rules must reach a monorepo workspace + * by inheriting the repo root's DESIGN.md. + * + * Run with: node --test tests/detect-cli-design-monorepo.test.mjs + */ + +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.resolve(__dirname, '../cli/bin/cli.js'); + +const PAGE_HTML = + '' + + '
Hi
'; + +const DESIGN_MD = `--- +typography: + body: + fontFamily: "Palatino, Georgia, serif" +--- +# Project A Design System +`; + +const tempRoots = []; + +function runDetect(cwd, targets, env = {}) { + const result = spawnSync(process.execPath, [CLI, 'detect', '--json', ...targets], { + cwd, + encoding: 'utf-8', + env: { ...process.env, ...env }, + }); + let findings = []; + try { + findings = JSON.parse(result.stdout || '[]'); + } catch { + throw new Error(`Non-JSON CLI output.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + } + return findings; +} + +function fontFindingsFor(findings, file) { + return findings.filter( + (f) => f.antipattern === 'design-system-font' && (!file || f.file === file), + ); +} + +function mkPnpmMonorepo({ workspaceDesign = null } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-pnpm-')); + tempRoots.push(dir); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n"); + fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}'); + const page = path.join(dir, 'apps/web/page.html'); + fs.writeFileSync(page, PAGE_HTML); + if (workspaceDesign) { + fs.writeFileSync(path.join(dir, 'apps/web/DESIGN.md'), workspaceDesign); + } + return { dir, page, webDir: path.join(dir, 'apps/web') }; +} + +after(() => { + for (const dir of tempRoots) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +describe('detect CLI monorepo DESIGN.md inheritance', () => { + it('pnpm workspace root: workspace page inherits root DESIGN.md', () => { + const { dir, page } = mkPnpmMonorepo(); + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'Verdana must be flagged via inherited root DESIGN.md', + ); + }); + + it('npm/yarn workspaces root: workspace page inherits root DESIGN.md', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-npm-')); + tempRoots.push(dir); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono","workspaces":["packages/*"]}'); + fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}'); + const page = path.join(dir, 'packages/ui/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'Verdana must be flagged via inherited root DESIGN.md', + ); + }); + + it('turbo marker root: workspace page inherits root DESIGN.md', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-turbo-')); + tempRoots.push(dir); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono"}'); + fs.writeFileSync(path.join(dir, 'turbo.json'), '{}'); + fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}'); + const page = path.join(dir, 'apps/web/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'Verdana must be flagged via inherited root DESIGN.md', + ); + }); + + it('directory target: scan apps/web dir inherits root DESIGN.md', () => { + const { dir, page, webDir } = mkPnpmMonorepo(); + const findings = runDetect(dir, [webDir]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'Verdana must be flagged when scanning the workspace directory', + ); + }); + + it('workspace-owned DESIGN.md wins over monorepo root', () => { + const workspaceDesign = `--- +typography: + body: + fontFamily: "Verdana, sans-serif" +--- +# Workspace Design System +`; + const { dir, page } = mkPnpmMonorepo({ workspaceDesign }); + const findings = runDetect(dir, [page]); + assert.equal( + fontFindingsFor(findings, page).length, + 0, + 'workspace DESIGN.md allowing Verdana must suppress inherited root rules', + ); + }); + + it('nested separate repo inherits nothing from monorepo root', () => { + const { dir } = mkPnpmMonorepo(); + fs.mkdirSync(path.join(dir, 'vendor/other/.git'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'vendor/other/package.json'), '{"name":"other"}'); + const page = path.join(dir, 'vendor/other/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.equal( + fontFindingsFor(findings, page).length, + 0, + 'nested repo with no workspaces must not inherit monorepo root DESIGN.md', + ); + }); + + it('home directory is never an owning monorepo root', () => { + // context.mjs's findMonorepoRoot stops at homeDir before its monorepo + // check; the engine walk must match, or a workspace-declaring $HOME + // leaks its DESIGN.md into every git-less project beneath it. + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-home-')); + tempRoots.push(home); + fs.writeFileSync(path.join(home, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(home, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n"); + fs.mkdirSync(path.join(home, 'project'), { recursive: true }); + fs.writeFileSync(path.join(home, 'project/package.json'), '{"name":"p"}'); + const page = path.join(home, 'project/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(home, [page], { HOME: home, USERPROFILE: home }); + assert.equal( + fontFindingsFor(findings, page).length, + 0, + 'a project under a workspace-declaring $HOME must not inherit its DESIGN.md', + ); + }); +}); From 91f2c7b47e72dc5d574432d7ddb5a2db8a5d4d59 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 14 Aug 2026 23:47:59 +0500 Subject: [PATCH 2/5] Fix: strip inline YAML comments when reading pnpm workspace globs (#570) An inline comment on a pnpm-workspace.yaml packages line defeated the end-anchored flow-list regex and the block-list state switch, so workspaces outside apps/ or packages/ went unrecognized. Reuses the engine's existing stripInlineYamlComment, matching context.mjs. Written with AI assistance (Cursor); reviewed by maintainer. Co-authored-by: Cursor --- cli/engine/design-system.mjs | 2 +- tests/detect-cli-design-monorepo.test.mjs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index 1f07d5815..2601c0d54 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -603,7 +603,7 @@ function readWorkspacePatterns(dir) { try { let inPackages = false; for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) { - const trimmed = line.trim(); + const trimmed = stripInlineYamlComment(line).trim(); if (!trimmed || trimmed.startsWith('#')) continue; const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); if (flow) { diff --git a/tests/detect-cli-design-monorepo.test.mjs b/tests/detect-cli-design-monorepo.test.mjs index c7767b6b4..1c4fe72c5 100644 --- a/tests/detect-cli-design-monorepo.test.mjs +++ b/tests/detect-cli-design-monorepo.test.mjs @@ -117,6 +117,23 @@ describe('detect CLI monorepo DESIGN.md inheritance', () => { ); }); + it('pnpm flow list with inline comment and non-standard dirs still detected', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-flow-')); + tempRoots.push(dir); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'packages: ["services/*"] # deploy targets\n'); + fs.mkdirSync(path.join(dir, 'services/api'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'services/api/package.json'), '{"name":"api"}'); + const page = path.join(dir, 'services/api/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'inline YAML comment must not defeat workspace-glob recognition', + ); + }); + it('directory target: scan apps/web dir inherits root DESIGN.md', () => { const { dir, page, webDir } = mkPnpmMonorepo(); const findings = runDetect(dir, [webDir]); From e975bec4125995b5265f400e36f0be70c3865a19 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 15 Aug 2026 01:07:52 +0500 Subject: [PATCH 3/5] Harden monorepo design-root recognition and the home-directory stop (#570) Read all four workspace-glob sources context.mjs reads (.impeccable projectRoots, package.json workspaces, lerna packages, pnpm packages), so lerna-glob roots and impeccable projectRoots no longer hit the same abstention. Compare the walk against both the logical and realpath forms of the home directory: on distros that symlink home paths (/home to /var/home) the string comparison never matched, and the post-boundary walk could inherit a workspace-declaring home's DESIGN.md. Written with AI assistance (Cursor); reviewed by maintainer. Co-authored-by: Cursor --- cli/engine/design-system.mjs | 34 +++++++++++-- tests/detect-cli-design-monorepo.test.mjs | 61 +++++++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index 2601c0d54..de180d093 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -597,9 +597,20 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) { // Returns { dir, hasDesign } for the stopping directory, or null when the walk // runs out. This is the fix for cross-project contamination. function readWorkspacePatterns(dir) { + const patterns = []; + // Same four glob sources as context.mjs's readProjectPatterns: Impeccable + // projectRoots, package.json workspaces, lerna packages, pnpm packages. + for (const name of ['config.json', 'config.local.json']) { + const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots; + if (Array.isArray(roots)) { + patterns.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim())); + } + } const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces; - const patterns = Array.isArray(workspaces) ? [...workspaces] - : Array.isArray(workspaces?.packages) ? [...workspaces.packages] : []; + if (Array.isArray(workspaces)) patterns.push(...workspaces); + else if (Array.isArray(workspaces?.packages)) patterns.push(...workspaces.packages); + const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages; + if (Array.isArray(lernaPackages)) patterns.push(...lernaPackages); try { let inPackages = false; for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) { @@ -632,9 +643,22 @@ function isMonorepoRoot(dir) { }); } +// Both forms of the home directory. The walk compares path strings, and a +// symlinked home (e.g. /home -> /var/home) never string-matches the physical +// paths a cwd-resolved target produces, which would let the post-boundary walk +// sail through $HOME and inherit from it. +function homeDirForms() { + const homeDir = path.resolve(os.homedir()); + const forms = new Set([homeDir]); + try { + forms.add(fs.realpathSync(homeDir)); + } catch { /* keep the logical form only */ } + return forms; +} + export function findDesignRoot(startDir) { let dir = path.resolve(startDir); - const homeDir = path.resolve(os.homedir()); + const homeDirs = homeDirForms(); let boundary = null; while (true) { if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true }; @@ -646,7 +670,7 @@ export function findDesignRoot(startDir) { // the walk with nothing inherited. The home directory is never an // owning root, same as context.mjs's findMonorepoRoot, which stops at // homeDir before its monorepo check. - if (dir !== homeDir && isMonorepoRoot(dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) }; + if (!homeDirs.has(dir) && isMonorepoRoot(dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) }; if (fs.existsSync(path.join(dir, '.git'))) return boundary; } else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) { boundary = { dir, hasDesign: false }; @@ -654,7 +678,7 @@ export function findDesignRoot(startDir) { // with its own .git, inherits nothing from above. if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary; } - if (dir === homeDir) return boundary; + if (homeDirs.has(dir)) return boundary; const parent = path.dirname(dir); if (parent === dir) return boundary; dir = parent; diff --git a/tests/detect-cli-design-monorepo.test.mjs b/tests/detect-cli-design-monorepo.test.mjs index 1c4fe72c5..db7b8c497 100644 --- a/tests/detect-cli-design-monorepo.test.mjs +++ b/tests/detect-cli-design-monorepo.test.mjs @@ -117,6 +117,41 @@ describe('detect CLI monorepo DESIGN.md inheritance', () => { ); }); + it('lerna packages globs: workspace outside apps/packages inherits root DESIGN.md', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-lerna-')); + tempRoots.push(dir); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'lerna.json'), '{"packages":["modules/*"]}'); + fs.mkdirSync(path.join(dir, 'modules/web'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'modules/web/package.json'), '{"name":"web"}'); + const page = path.join(dir, 'modules/web/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'lerna packages globs must be read as workspace declarations', + ); + }); + + it('impeccable projectRoots: workspace inherits root DESIGN.md', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-iroots-')); + tempRoots.push(dir); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.mkdirSync(path.join(dir, '.impeccable'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.impeccable/config.json'), '{"projectRoots":["sites/*"]}'); + fs.mkdirSync(path.join(dir, 'sites/docs'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'sites/docs/package.json'), '{"name":"docs"}'); + const page = path.join(dir, 'sites/docs/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'impeccable projectRoots must be read as workspace declarations', + ); + }); + it('pnpm flow list with inline comment and non-standard dirs still detected', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-flow-')); tempRoots.push(dir); @@ -195,4 +230,30 @@ typography: 'a project under a workspace-declaring $HOME must not inherit its DESIGN.md', ); }); + + it('symlinked $HOME still stops the walk', () => { + // Some distros symlink home paths (/home -> /var/home), so $HOME never + // string-matches the physical paths a cwd-resolved target produces. The + // walk must compare against the realpath form too. + const real = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-realhome-')); + tempRoots.push(real); + const link = path.join(os.tmpdir(), `impeccable-detect-mono-linkhome-${path.basename(real).slice(-6)}`); + fs.symlinkSync(real, link); + tempRoots.push(link); + fs.writeFileSync(path.join(real, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(real, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n"); + fs.mkdirSync(path.join(real, 'project'), { recursive: true }); + fs.writeFileSync(path.join(real, 'project/package.json'), '{"name":"p"}'); + const page = path.join(real, 'project/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + // HOME is the symlink; the target is passed via its physical path, so a + // logical-only comparison would walk straight past home and inherit. + const findings = runDetect(real, [fs.realpathSync(page)], { HOME: link, USERPROFILE: link }); + assert.equal( + fontFindingsFor(findings, page).length + fontFindingsFor(findings, fs.realpathSync(page)).length, + 0, + 'a symlinked $HOME must still stop the walk before inheriting', + ); + }); }); From 5d7c1cce345bc553ed648fc2dc983781fa91d627 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Tue, 25 Aug 2026 07:47:16 +0500 Subject: [PATCH 4/5] Fix: inherit DESIGN.md only from a monorepo root that owns the path (#570) findDesignRoot continued past every workspace package.json to any workspace-declaring ancestor. It now matches the boundary against that ancestor's globs (including negations and globstars), so excluded and stray packages do not inherit, while included workspaces still do. Written with AI assistance (Cursor); reviewed by maintainer. Co-authored-by: Cursor --- cli/engine/design-system.mjs | 95 +++++++- tests/detect-cli-design-monorepo.test.mjs | 279 ++++++++++++++++++++++ 2 files changed, 365 insertions(+), 9 deletions(-) diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index de180d093..5dd612514 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -586,10 +586,13 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) { // - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the // design root — that's where the rules live. // - A directory carrying a project marker (.git / package.json / .impeccable) -// but no DESIGN.md is a project BOUNDARY. When that boundary is a monorepo -// workspace (owned by a workspace-declaring root above it, recognized the -// same way context.mjs does), the workspace inherits the monorepo root's -// DESIGN.md; a nested separate repository (.git with no workspace +// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits +// the ancestor DESIGN.md only when that ancestor's workspace declarations +// include the path (negations win). Marker-only roots (turbo/nx/lerna/pnpm +// with no globs) still own apps/ and packages/. A stray nested +// package that matches no glob does not inherit. This is detect's +// contamination contract, not skill-context's repoRoot fallback for +// excluded paths. A nested separate repository (.git with no workspace // declaration) still inherits nothing (issue #570). // - Reaching the home directory / filesystem root with neither means no // design system at all — never process.cwd()'s. @@ -643,6 +646,76 @@ function isMonorepoRoot(dir) { }); } +function monorepoOwnsPath(root, boundaryDir) { + const rel = path.relative(root, boundaryDir); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false; + const relSegments = rel.split(path.sep).filter(Boolean); + + function normalizeWorkspacePattern(pattern) { + return String(pattern || '') + .trim() + .replace(/^['"]|['"]$/g, '') + .replace(/^\.\//, '') + .replace(/\/+$/, ''); + } + + function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function segmentMatches(patternSegment, relSegment) { + if (patternSegment === '*') return true; + if (!patternSegment.includes('*')) return patternSegment === relSegment; + const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`); + return re.test(relSegment); + } + + function matchGlobSegments(patternSegments, relSegments) { + function rec(pi, ri) { + if (pi === patternSegments.length) return ri === relSegments.length; + if (patternSegments[pi] === '**') { + if (pi === patternSegments.length - 1) return true; + for (let k = ri; k <= relSegments.length; k++) { + if (rec(pi + 1, k)) return true; + } + return false; + } + if (ri >= relSegments.length) return false; + if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false; + return rec(pi + 1, ri + 1); + } + return rec(0, 0); + } + + // allowPrefix: negations like !packages/excluded must also cover nested dirs + // under that path. Positive globs are exact (npm `*` is direct children only). + function matches(pattern, { allowPrefix = false } = {}) { + const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); + if (!patternSegments.length) return false; + if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments); + if (allowPrefix) { + if (relSegments.length < patternSegments.length) return false; + } else if (relSegments.length !== patternSegments.length) { + return false; + } + for (let i = 0; i < patternSegments.length; i++) { + if (!segmentMatches(patternSegments[i], relSegments[i])) return false; + } + return true; + } + + const patterns = readWorkspacePatterns(root) + .map(normalizeWorkspacePattern) + .filter(Boolean); + if (patterns.some((pattern) => pattern.startsWith('!') && matches(pattern.slice(1), { allowPrefix: true }))) { + return false; + } + const positive = patterns.filter((pattern) => !pattern.startsWith('!')); + if (positive.some((pattern) => matches(pattern))) return true; + if (positive.length) return false; + return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]); +} + // Both forms of the home directory. The walk compares path strings, and a // symlinked home (e.g. /home -> /var/home) never string-matches the physical // paths a cwd-resolved target produces, which would let the post-boundary walk @@ -664,13 +737,17 @@ export function findDesignRoot(startDir) { if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true }; if (boundary) { // Past the boundary the walk only looks for the monorepo root that owns - // the workspace. Monorepo-root before .git, same order as context.mjs: - // a workspace root carrying its own .git is still recognized, while a - // .git that declares no workspaces is a separate repository and stops - // the walk with nothing inherited. The home directory is never an + // the workspace path (workspace globs including negations, or marker-only + // apps/packages fallback). Monorepo-root before .git, same order as + // context.mjs: a workspace root carrying its own .git is still recognized, + // while a .git that declares no workspaces is a separate repository and + // stops the walk with nothing inherited. The home directory is never an // owning root, same as context.mjs's findMonorepoRoot, which stops at // homeDir before its monorepo check. - if (!homeDirs.has(dir) && isMonorepoRoot(dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) }; + if (!homeDirs.has(dir) && isMonorepoRoot(dir)) { + if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) }; + return boundary; + } if (fs.existsSync(path.join(dir, '.git'))) return boundary; } else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) { boundary = { dir, hasDesign: false }; diff --git a/tests/detect-cli-design-monorepo.test.mjs b/tests/detect-cli-design-monorepo.test.mjs index db7b8c497..a8ffdec31 100644 --- a/tests/detect-cli-design-monorepo.test.mjs +++ b/tests/detect-cli-design-monorepo.test.mjs @@ -13,6 +13,8 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { findDesignRoot } from '../cli/engine/design-system.mjs'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI = path.resolve(__dirname, '../cli/bin/cli.js'); @@ -66,6 +68,12 @@ function mkPnpmMonorepo({ workspaceDesign = null } = {}) { return { dir, page, webDir: path.join(dir, 'apps/web') }; } +function mkTempRoot(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempRoots.push(dir); + return dir; +} + after(() => { for (const dir of tempRoots) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } @@ -256,4 +264,275 @@ typography: 'a symlinked $HOME must still stop the walk before inheriting', ); }); + + it('CSS module at apps/web/app/page.module.css inherits root DESIGN.md', () => { + const dir = mkTempRoot('impeccable-detect-mono-cssmod-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n"); + fs.mkdirSync(path.join(dir, 'apps/web/app'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}'); + const css = path.join(dir, 'apps/web/app/page.module.css'); + fs.writeFileSync(css, '.c{font-family:Verdana,sans-serif}'); + + const findings = runDetect(dir, [css]); + assert.ok( + fontFindingsFor(findings, css).some((f) => f.ignoreValue?.toLowerCase() === 'verdana'), + 'Verdana in a CSS module must be flagged via inherited root DESIGN.md', + ); + }); + + it('yarn workspaces object form: workspace inherits root DESIGN.md', () => { + const dir = mkTempRoot('impeccable-detect-mono-yarnobj-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'mono', + workspaces: { packages: ['packages/*'] }, + })); + fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}'); + const page = path.join(dir, 'packages/ui/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'yarn workspaces object form must inherit root DESIGN.md', + ); + }); + + it('nx.json marker root: workspace inherits root DESIGN.md', () => { + const dir = mkTempRoot('impeccable-detect-mono-nx-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono"}'); + fs.writeFileSync(path.join(dir, 'nx.json'), '{}'); + fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}'); + const page = path.join(dir, 'apps/web/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'nx.json marker root must inherit root DESIGN.md', + ); + }); + + it('file at monorepo root still flags against root DESIGN.md', () => { + const dir = mkTempRoot('impeccable-detect-mono-rootfile-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n"); + const page = path.join(dir, 'page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'root-level file must be judged against root DESIGN.md', + ); + }); + + it('scan from different cwd still inherits via target path', () => { + const { page } = mkPnpmMonorepo(); + const otherCwd = mkTempRoot('impeccable-detect-mono-othercwd-'); + + const findings = runDetect(otherCwd, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'resolution must follow the target path, not process.cwd()', + ); + }); + + it('findDesignRoot(apps/web) returns monorepo root with hasDesign true', () => { + const { dir, webDir } = mkPnpmMonorepo(); + const found = findDesignRoot(webDir); + assert.equal(found.dir, dir); + assert.equal(found.hasDesign, true); + }); + + it('negated workspace package does not inherit root DESIGN.md (Greptile P1)', () => { + const dir = mkTempRoot('impeccable-detect-mono-negated-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'mono', + workspaces: ['packages/*', '!packages/excluded'], + })); + fs.mkdirSync(path.join(dir, 'packages/included'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/included/package.json'), '{"name":"included"}'); + const includedPage = path.join(dir, 'packages/included/page.html'); + fs.writeFileSync(includedPage, PAGE_HTML); + fs.mkdirSync(path.join(dir, 'packages/excluded'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/excluded/package.json'), '{"name":"excluded"}'); + const excludedPage = path.join(dir, 'packages/excluded/page.html'); + fs.writeFileSync(excludedPage, PAGE_HTML); + const excludedDir = path.join(dir, 'packages/excluded'); + + const findings = runDetect(dir, [includedPage, excludedPage]); + assert.ok( + fontFindingsFor(findings, includedPage).some((f) => f.ignoreValue === 'verdana'), + 'included workspace package must inherit root DESIGN.md', + ); + assert.equal( + fontFindingsFor(findings, excludedPage).length, + 0, + 'negated workspace package must not inherit root DESIGN.md', + ); + const excludedRoot = findDesignRoot(excludedDir); + assert.equal(excludedRoot.dir, excludedDir); + assert.equal(excludedRoot.hasDesign, false); + }); + + it('stray nested package outside globs does not inherit root DESIGN.md', () => { + const dir = mkTempRoot('impeccable-detect-mono-stray-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n"); + fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}'); + const webPage = path.join(dir, 'apps/web/page.html'); + fs.writeFileSync(webPage, PAGE_HTML); + fs.mkdirSync(path.join(dir, 'vendor/tool'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'vendor/tool/package.json'), '{"name":"tool"}'); + const vendorPage = path.join(dir, 'vendor/tool/page.html'); + fs.writeFileSync(vendorPage, PAGE_HTML); + + const findings = runDetect(dir, [webPage, vendorPage]); + assert.ok( + fontFindingsFor(findings, webPage).some((f) => f.ignoreValue === 'verdana'), + 'apps/web must inherit root DESIGN.md', + ); + assert.equal( + fontFindingsFor(findings, vendorPage).length, + 0, + 'vendor/tool outside globs must not inherit root DESIGN.md', + ); + }); + + it('non-monorepo nested package.json does not inherit root DESIGN.md', () => { + const dir = mkTempRoot('impeccable-detect-mono-nestedpkg-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"root"}'); + fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'packages/nested'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/nested/package.json'), '{"name":"nested"}'); + const nestedPage = path.join(dir, 'packages/nested/page.html'); + fs.writeFileSync(nestedPage, PAGE_HTML); + + const findings = runDetect(dir, [nestedPage]); + assert.equal( + fontFindingsFor(findings, nestedPage).length, + 0, + 'nested package.json in a non-monorepo must not inherit root DESIGN.md', + ); + }); + + it('non-monorepo without DESIGN.md: no design-system-font findings', () => { + const dir = mkTempRoot('impeccable-detect-mono-nodesign-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"root"}'); + const page = path.join(dir, 'page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.equal( + fontFindingsFor(findings, page).length, + 0, + 'no DESIGN.md means no design-system-font findings', + ); + }); + + it('single-package repo: src/page.html inherits root DESIGN.md', () => { + const dir = mkTempRoot('impeccable-detect-mono-single-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app"}'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + const page = path.join(dir, 'src/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'src/ without its own package.json must inherit project DESIGN.md', + ); + }); + + it('DESIGN.md in docs/ fallback still flags in single-package repo', () => { + const dir = mkTempRoot('impeccable-detect-mono-docsfb-'); + fs.mkdirSync(path.join(dir, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'docs/DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app"}'); + const page = path.join(dir, 'src/page.html'); + fs.mkdirSync(path.dirname(page), { recursive: true }); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'docs/DESIGN.md fallback must apply to nested files', + ); + }); + + it('pnpm !**/test/** does not smash sibling workspaces', () => { + const dir = mkTempRoot('impeccable-detect-mono-globstar-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), [ + 'packages:', + " - 'packages/*'", + " - 'components/**'", + " - '!**/test/**'", + '', + ].join('\n')); + fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}'); + const uiPage = path.join(dir, 'packages/ui/page.html'); + fs.writeFileSync(uiPage, PAGE_HTML); + fs.mkdirSync(path.join(dir, 'components/button'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'components/button/package.json'), '{"name":"button"}'); + const buttonPage = path.join(dir, 'components/button/page.html'); + fs.writeFileSync(buttonPage, PAGE_HTML); + fs.mkdirSync(path.join(dir, 'packages/ui/test/fixture'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/ui/test/fixture/package.json'), '{"name":"fixture"}'); + const testPage = path.join(dir, 'packages/ui/test/fixture/page.html'); + fs.writeFileSync(testPage, PAGE_HTML); + + const findings = runDetect(dir, [uiPage, buttonPage, testPage]); + assert.ok( + fontFindingsFor(findings, uiPage).some((f) => f.ignoreValue === 'verdana'), + 'packages/ui must still inherit when a globstar test exclusion is present', + ); + assert.ok( + fontFindingsFor(findings, buttonPage).some((f) => f.ignoreValue === 'verdana'), + 'components/** must still inherit when a globstar test exclusion is present', + ); + assert.equal( + fontFindingsFor(findings, testPage).length, + 0, + 'packages/ui/test/fixture must not inherit under !**/test/**', + ); + }); + + it('workspaces ["*"] owns only direct children, not vendor/tool', () => { + const dir = mkTempRoot('impeccable-detect-mono-star-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'mono', + workspaces: ['*'], + })); + fs.mkdirSync(path.join(dir, 'web'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'web/package.json'), '{"name":"web"}'); + const webPage = path.join(dir, 'web/page.html'); + fs.writeFileSync(webPage, PAGE_HTML); + fs.mkdirSync(path.join(dir, 'vendor/tool'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'vendor/tool/package.json'), '{"name":"tool"}'); + const vendorPage = path.join(dir, 'vendor/tool/page.html'); + fs.writeFileSync(vendorPage, PAGE_HTML); + + const findings = runDetect(dir, [webPage, vendorPage]); + assert.ok( + fontFindingsFor(findings, webPage).some((f) => f.ignoreValue === 'verdana'), + 'direct-child workspace under * must inherit root DESIGN.md', + ); + assert.equal( + fontFindingsFor(findings, vendorPage).length, + 0, + 'vendor/tool is not a direct child of * and must not inherit', + ); + }); }); From 47e411952b377c2eb247aa25a7ba8118e6cb64f7 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Tue, 25 Aug 2026 08:03:16 +0500 Subject: [PATCH 5/5] Fix: own nested workspace packages and honor projectRoots first (#570) packages/* now includes nested package.json dirs under a matched workspace package, and Impeccable projectRoots govern a path even when package-manager workspaces exclude it. Written with AI assistance (Cursor); reviewed by maintainer. Co-authored-by: Cursor --- cli/engine/design-system.mjs | 119 ++++++++++++++-------- tests/detect-cli-design-monorepo.test.mjs | 55 +++++++++- 2 files changed, 128 insertions(+), 46 deletions(-) diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index 5dd612514..28e01b1d2 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -580,40 +580,23 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) { } } -// Walk up from `startDir` to the directory that governs the target's design -// system, mirroring skill/scripts/context.mjs's project-boundary semantics: -// -// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the -// design root — that's where the rules live. -// - A directory carrying a project marker (.git / package.json / .impeccable) -// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits -// the ancestor DESIGN.md only when that ancestor's workspace declarations -// include the path (negations win). Marker-only roots (turbo/nx/lerna/pnpm -// with no globs) still own apps/ and packages/. A stray nested -// package that matches no glob does not inherit. This is detect's -// contamination contract, not skill-context's repoRoot fallback for -// excluded paths. A nested separate repository (.git with no workspace -// declaration) still inherits nothing (issue #570). -// - Reaching the home directory / filesystem root with neither means no -// design system at all — never process.cwd()'s. -// -// Returns { dir, hasDesign } for the stopping directory, or null when the walk -// runs out. This is the fix for cross-project contamination. -function readWorkspacePatterns(dir) { - const patterns = []; - // Same four glob sources as context.mjs's readProjectPatterns: Impeccable - // projectRoots, package.json workspaces, lerna packages, pnpm packages. +// Same two groups as context.mjs's readProjectPatternGroups: Impeccable +// projectRoots govern any path they match (positive or negated); package-manager +// globs only apply to paths the Impeccable group does not match. +function readWorkspacePatternGroups(dir) { + const impeccable = []; for (const name of ['config.json', 'config.local.json']) { const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots; if (Array.isArray(roots)) { - patterns.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim())); + impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim())); } } + const pkg = []; const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces; - if (Array.isArray(workspaces)) patterns.push(...workspaces); - else if (Array.isArray(workspaces?.packages)) patterns.push(...workspaces.packages); + if (Array.isArray(workspaces)) pkg.push(...workspaces); + else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages); const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages; - if (Array.isArray(lernaPackages)) patterns.push(...lernaPackages); + if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages); try { let inPackages = false; for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) { @@ -621,17 +604,21 @@ function readWorkspacePatterns(dir) { if (!trimmed || trimmed.startsWith('#')) continue; const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); if (flow) { - patterns.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)); + pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)); break; } if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; } if (!inPackages) continue; const item = trimmed.match(/^-\s*(.+)$/); - if (item) patterns.push(item[1].trim().replace(/^['"]|['"]$/g, '')); + if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, '')); else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break; } } catch { /* no pnpm-workspace.yaml */ } - return patterns; + return [impeccable, pkg]; +} + +function readWorkspacePatterns(dir) { + return readWorkspacePatternGroups(dir).flat(); } function isMonorepoRoot(dir) { @@ -687,32 +674,54 @@ function monorepoOwnsPath(root, boundaryDir) { return rec(0, 0); } - // allowPrefix: negations like !packages/excluded must also cover nested dirs - // under that path. Positive globs are exact (npm `*` is direct children only). - function matches(pattern, { allowPrefix = false } = {}) { + // Negations like !packages/excluded must also cover nested dirs under that path. + function matchesNegation(pattern) { const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); if (!patternSegments.length) return false; if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments); - if (allowPrefix) { - if (relSegments.length < patternSegments.length) return false; - } else if (relSegments.length !== patternSegments.length) { - return false; - } + if (relSegments.length < patternSegments.length) return false; for (let i = 0; i < patternSegments.length; i++) { if (!segmentMatches(patternSegments[i], relSegments[i])) return false; } return true; } - const patterns = readWorkspacePatterns(root) - .map(normalizeWorkspacePattern) - .filter(Boolean); - if (patterns.some((pattern) => pattern.startsWith('!') && matches(pattern.slice(1), { allowPrefix: true }))) { + // Positive globs identify workspace packages at exact depth (`*` is a direct + // child). A nested package.json under that package is still owned: the + // ancestor directory of glob length must itself be a package. + function positiveOwns(pattern) { + const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); + if (!patternSegments.length) return false; + if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments); + if (relSegments.length < patternSegments.length) return false; + for (let i = 0; i < patternSegments.length; i++) { + if (!segmentMatches(patternSegments[i], relSegments[i])) return false; + } + if (relSegments.length === patternSegments.length) return true; + const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length)); + return fs.existsSync(path.join(ancestorDir, 'package.json')); + } + + function groupOwns(rawPatterns) { + const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean); + if (!patterns.length) return null; + const excluded = patterns.some((pattern) => ( + pattern.startsWith('!') && matchesNegation(pattern.slice(1)) + )); + const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns); + if (!excluded && !included) return null; + if (excluded) return false; + return true; + } + + const [impeccable, pkg] = readWorkspacePatternGroups(root); + const fromImpeccable = groupOwns(impeccable); + if (fromImpeccable !== null) return fromImpeccable; + const fromPkg = groupOwns(pkg); + if (fromPkg !== null) return fromPkg; + if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) { return false; } - const positive = patterns.filter((pattern) => !pattern.startsWith('!')); - if (positive.some((pattern) => matches(pattern))) return true; - if (positive.length) return false; return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]); } @@ -729,6 +738,26 @@ function homeDirForms() { return forms; } +// Walk up from `startDir` to the directory that governs the target's design +// system, mirroring skill/scripts/context.mjs's project-boundary semantics: +// +// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the +// design root — that's where the rules live. +// - A directory carrying a project marker (.git / package.json / .impeccable) +// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits +// the ancestor DESIGN.md only when that ancestor's workspace declarations +// include the path (negations win; a nested package under a matched +// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm +// with no globs) still own apps/ and packages/. A stray nested +// package that matches no glob does not inherit. This is detect's +// contamination contract, not skill-context's repoRoot fallback for +// excluded paths. A nested separate repository (.git with no workspace +// declaration) still inherits nothing (issue #570). +// - Reaching the home directory / filesystem root with neither means no +// design system at all — never process.cwd()'s. +// +// Returns { dir, hasDesign } for the stopping directory, or null when the walk +// runs out. This is the fix for cross-project contamination. export function findDesignRoot(startDir) { let dir = path.resolve(startDir); const homeDirs = homeDirForms(); diff --git a/tests/detect-cli-design-monorepo.test.mjs b/tests/detect-cli-design-monorepo.test.mjs index a8ffdec31..e57324dcd 100644 --- a/tests/detect-cli-design-monorepo.test.mjs +++ b/tests/detect-cli-design-monorepo.test.mjs @@ -519,20 +519,73 @@ typography: fs.writeFileSync(path.join(dir, 'web/package.json'), '{"name":"web"}'); const webPage = path.join(dir, 'web/page.html'); fs.writeFileSync(webPage, PAGE_HTML); + fs.mkdirSync(path.join(dir, 'web/examples'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'web/examples/package.json'), '{"name":"examples"}'); + const nestedPage = path.join(dir, 'web/examples/page.html'); + fs.writeFileSync(nestedPage, PAGE_HTML); fs.mkdirSync(path.join(dir, 'vendor/tool'), { recursive: true }); fs.writeFileSync(path.join(dir, 'vendor/tool/package.json'), '{"name":"tool"}'); const vendorPage = path.join(dir, 'vendor/tool/page.html'); fs.writeFileSync(vendorPage, PAGE_HTML); - const findings = runDetect(dir, [webPage, vendorPage]); + const findings = runDetect(dir, [webPage, nestedPage, vendorPage]); assert.ok( fontFindingsFor(findings, webPage).some((f) => f.ignoreValue === 'verdana'), 'direct-child workspace under * must inherit root DESIGN.md', ); + assert.ok( + fontFindingsFor(findings, nestedPage).some((f) => f.ignoreValue === 'verdana'), + 'nested package under a * workspace child must still inherit', + ); assert.equal( fontFindingsFor(findings, vendorPage).length, 0, 'vendor/tool is not a direct child of * and must not inherit', ); }); + + it('nested package under an included workspace inherits root DESIGN.md', () => { + const dir = mkTempRoot('impeccable-detect-mono-nestedws-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'mono', + workspaces: ['packages/*'], + })); + fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}'); + fs.mkdirSync(path.join(dir, 'packages/ui/examples'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/ui/examples/package.json'), '{"name":"examples"}'); + const page = path.join(dir, 'packages/ui/examples/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'packages/ui/examples must inherit as nested content of packages/*', + ); + const found = findDesignRoot(path.join(dir, 'packages/ui/examples')); + assert.equal(found.dir, dir); + assert.equal(found.hasDesign, true); + }); + + it('impeccable projectRoots beat a package-manager negation of the same path', () => { + const dir = mkTempRoot('impeccable-detect-mono-iroots-win-'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + fs.mkdirSync(path.join(dir, '.impeccable'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.impeccable/config.json'), '{"projectRoots":["sites/*"]}'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'mono', + workspaces: ['sites/*', '!sites/docs'], + })); + fs.mkdirSync(path.join(dir, 'sites/docs'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'sites/docs/package.json'), '{"name":"docs"}'); + const page = path.join(dir, 'sites/docs/page.html'); + fs.writeFileSync(page, PAGE_HTML); + + const findings = runDetect(dir, [page]); + assert.ok( + fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'), + 'projectRoots must govern a path they match even when workspaces exclude it', + ); + }); });