Compare commits

...
Author SHA1 Message Date
Abdul WahabandCursor daae1d4117 Fix: reject root-relative .. segments and warn per scan
Dot-segment hrefs like /../outside.css could leave the project, and a process-wide warning set hid missing-sheet notices on later detectHtml calls.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:47:54 +05:00
Abdul WahabandCursor 2b88aa5231 Fix: resolve root-relative linked stylesheets in static detect (#652)
Root-relative hrefs like /static/app.css were treated as OS-absolute and silently dropped, hiding contrast findings.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:34:55 +05:00
github-actions[bot] fcd7622cd2 Sync generated provider output 2026-08-25 12:17:17 +00:00
Abdul WahabandGitHub 356b761391 Merge pull request #594 from pbakaus/fix/570-monorepo-design-root
Fix: inherit the monorepo root's DESIGN.md in detect design-system rules (#570)
2026-08-25 17:16:47 +05:00
github-actions[bot] 1159100c96 Sync generated provider output 2026-08-25 11:17:54 +00:00
Abdul WahabandGitHub 0e9b6f9884 Merge pull request #651 from pbakaus/fix/573-context-windows-teardown
Fix: close fetch sockets before context helper exit (#573)
2026-08-25 16:17:11 +05:00
Abdul WahabandCursor 47e411952b 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 <cursoragent@cursor.com>
2026-08-25 08:03:16 +05:00
Abdul WahabandCursor 5d7c1cce34 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 <cursoragent@cursor.com>
2026-08-25 07:47:16 +05:00
Abdul Wahab 043e8a5bfd Merge origin/main into fix/570-monorepo-design-root 2026-08-25 06:58:14 +05:00
Abdul WahabandCursor e975bec412 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 <cursoragent@cursor.com>
2026-08-15 01:07:52 +05:00
Abdul WahabandCursor 91f2c7b47e 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 <cursoragent@cursor.com>
2026-08-14 23:47:59 +05:00
Abdul WahabandCursor dca8f1ca6f 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 <cursoragent@cursor.com>
2026-08-14 23:41:29 +05:00
37 changed files with 4405 additions and 189 deletions
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+196 -8
View File
@@ -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/<name> and packages/<name>. 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;
}
}
+38 -5
View File
@@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) {
};
}
function resolveLinkedCssPath(fileDir, href) {
const stripped = href.split(/[?#]/)[0];
const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//');
if (!rootRelative) return path.resolve(fileDir, stripped);
// Drop "." and reject ".." so /../outside.css cannot walk out of dir.
const segments = stripped.replace(/^\/+/, '').split(/[/\\]/).filter(p => p && p !== '.');
if (segments.some(p => p === '..')) return path.join(fileDir, segments.filter(p => p !== '..').join(path.sep));
const rel = segments.join(path.sep);
let dir = fileDir;
for (;;) {
const parent = path.dirname(dir);
if (parent === dir) break; // never use the filesystem root as document root
try {
const candidate = path.join(dir, rel);
if (fs.statSync(candidate).isFile()) return candidate;
} catch { /* missing or unreadable candidate */ }
// Stop at the project root so a coincidental ~/static/app.css cannot win.
try {
if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) break;
} catch { /* unreadable marker */ }
dir = parent;
}
return path.join(fileDir, rel);
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
const warnedMissingStylesheets = new Set();
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
@@ -974,10 +1000,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
// literal path with the query in it; a versioned link otherwise made the
// whole stylesheet invisible to every element-level check.
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
// invisible to every element-level check.
const cssPath = resolveLinkedCssPath(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
@@ -987,7 +1013,14 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
} catch {
if (!warnedMissingStylesheets.has(cssPath)) {
warnedMissingStylesheets.add(cssPath);
process.stderr.write(
`impeccable detect: could not read linked stylesheet ${href} (resolved to ${cssPath}); color and custom-property rules will be incomplete\n`
);
}
}
}
return styleTexts.join('\n');
}
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -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/<name> and packages/<name>. 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;
}
}
+1
View File
@@ -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',
],
},
+75
View File
@@ -1321,6 +1321,81 @@ describe('detectHtml — static HTML/CSS engine', () => {
expect(findingIds(f)).toContain('side-tab');
});
test('resolves root-relative linked stylesheets with cache-busting query', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/static/app.css?v=3">
</head><body><div class="card">Card</div></body></html>`,
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
}, async ({ file }) => {
const f = await detectHtml(file);
expect(findingIds(f)).toContain('side-tab');
});
});
test('resolves root-relative linked stylesheets from nested pages via ancestor walk', async () => {
await withStaticFixture({
'pages/about.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/static/app.css">
</head><body><div class="card">Card</div></body></html>`,
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
}, async ({ dir }) => {
const f = await detectHtml(path.join(dir, 'pages', 'about.html'));
expect(findingIds(f)).toContain('side-tab');
});
});
test('does not resolve root-relative sheets above the project root', async () => {
await withStaticFixture({
'project/package.json': '{}',
'project/index.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/static/app.css">
</head><body><div class="card">Card</div></body></html>`,
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
}, async ({ dir }) => {
const f = await detectHtml(path.join(dir, 'project', 'index.html'));
expect(findingIds(f)).not.toContain('side-tab');
});
});
test('does not follow root-relative .. segments out of the page directory', async () => {
await withStaticFixture({
'project/package.json': '{}',
'project/index.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/../outside.css">
</head><body><div class="card">Card</div></body></html>`,
'outside.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
}, async ({ dir }) => {
const f = await detectHtml(path.join(dir, 'project', 'index.html'));
expect(findingIds(f)).not.toContain('side-tab');
});
});
test('warns when a linked stylesheet cannot be read', async () => {
const writes = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...args) => {
writes.push(String(chunk));
return origWrite(chunk, ...args);
};
try {
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/missing/app.css">
</head><body><div>Page</div></body></html>`,
}, async ({ file, dir }) => {
await detectHtml(file);
await detectHtml(file);
const msg = writes.join('');
const hits = msg.split('could not read linked stylesheet /missing/app.css').length - 1;
expect(hits).toBe(2);
expect(msg).toContain(`resolved to ${path.join(dir, 'missing', 'app.css')}`);
});
} finally {
process.stderr.write = origWrite;
}
});
test('gradient-text: a style="" attribute alone carries the page-level flag', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head><title>t</title></head><body>
+591
View File
@@ -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 =
'<!doctype html><html><head><style>.card { font-family: Verdana, sans-serif; }</style></head>' +
'<body><div class="card">Hi</div></body></html>';
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',
);
});
});