diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index 5c9a949e6..28e01b1d2 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 @@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) { } } +// 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)) { + 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)) 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)) pkg.push(...lernaPackages); + try { + let inPackages = false; + for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) { + const trimmed = stripInlineYamlComment(line).trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); + if (flow) { + 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) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, '')); + else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break; + } + } catch { /* no pnpm-workspace.yaml */ } + return [impeccable, pkg]; +} + +function readWorkspacePatterns(dir) { + return readWorkspacePatternGroups(dir).flat(); +} + +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; + } + }); +} + +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); + } + + // 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 (relSegments.length < patternSegments.length) return false; + for (let i = 0; i < patternSegments.length; i++) { + if (!segmentMatches(patternSegments[i], relSegments[i])) return false; + } + return 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; + } + 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 +// 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; +} + // 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: 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. 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. // @@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) { // runs out. This is the fix for cross-project contamination. export function findDesignRoot(startDir) { let dir = path.resolve(startDir); - const homeDir = path.resolve(os.homedir()); + const homeDirs = homeDirForms(); + 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 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)) { + 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 }; + // 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 (homeDirs.has(dir)) 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..e57324dcd --- /dev/null +++ b/tests/detect-cli-design-monorepo.test.mjs @@ -0,0 +1,591 @@ +/** + * 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'; + +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'); + +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') }; +} + +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 */ } + } +}); + +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('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); + 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]); + 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', + ); + }); + + 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', + ); + }); + + 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, '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, 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', + ); + }); +});