Compare commits

...
Author SHA1 Message Date
Abdul WahabandCursor 165f94e939 Fix: keep URL basic-auth credentials on the scan origin (#657)
page.authenticate is page-wide, so a cross-origin redirect that then 401s would receive the original credentials. Attach Authorization only to requests for the scan origin.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:36:40 +05:00
Abdul WahabandCursor c9c6928be6 Fix: redact URL userinfo from detect findings (#657)
Strip basic-auth credentials from scan-target URLs before goto and finding output, and pass them to page.authenticate instead.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:26:41 +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 6bea544a0a Fix: drain context stdout before process.exit (#573)
process.exit after a queued write truncated boot output on a backpressured pipe. Await the write callback, then close the fetch dispatcher.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 07:51:47 +05:00
Abdul WahabandCursor 2ef8e43d1e Fix: close fetch sockets before context helper exit (#573)
On Windows/Node 24, a live undici keep-alive from the update-check fetch aborted libuv during teardown after valid stdout. Destroy the dispatcher first, matching concept-seed.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 07:31:18 +05:00
36 changed files with 3861 additions and 182 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;
}
}
+64 -2
View File
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
+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) {
+56
View File
@@ -1060,6 +1060,27 @@ describe('context.mjs CLI', () => {
assert.match(res.stdout, /detect\.mjs --json <changed targets>/);
});
it('drains stdout before exit when the parent pipe is paused', async () => {
const MARKER = 'END_MARKER_573';
write('PRODUCT.md', `# Acme\n\n${'x'.repeat(256 * 1024)}\n\n${MARKER}\n`);
const child = spawn(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
let stdout = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stdout.pause();
const resume = setTimeout(() => child.stdout.resume(), 100);
const status = await new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', resolve);
});
clearTimeout(resume);
assert.equal(status, 0);
assert.match(stdout, /END_MARKER_573/);
assert.match(stdout, /RESOLVED_CONTEXT:/);
});
// The build-path preference rides the unified config beside hook and
// detector settings. The local file wins because whether a machine can
// generate images is a property of that machine, not of the committed
@@ -1588,4 +1609,39 @@ describe('context.mjs update check', () => {
assert.equal(typeof cache.lastCheck, 'number'); // stamped so we don't re-poll every boot
assert.equal(cache.latestVersion, undefined); // nothing learned
});
// Targeted live-fetch boot: the Windows abort in issue #573 fired after
// stdout was already complete, so the contract is exit 0 with the full
// context still on stdout.
it('exits 0 after a targeted live-fetch boot writes full context', async () => {
const { srv, host } = await startStub({ skills: '2.0.0' });
try {
const { skillScript, project, env } = setup({}, { host });
fs.writeFileSync(
path.join(project, 'package.json'),
JSON.stringify({ private: true, workspaces: ['packages/*'] }),
);
const jervPi = path.join(project, 'packages', 'jerv-pi');
fs.mkdirSync(jervPi, { recursive: true });
fs.writeFileSync(path.join(jervPi, 'PRODUCT.md'), '# Jerv Pi product\n');
const result = await new Promise((resolveRun, rejectRun) => {
const child = spawn(process.execPath, [skillScript, '--target', 'packages/jerv-pi'], {
cwd: project,
env,
});
let stdout = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.on('error', rejectRun);
child.on('close', (status) => resolveRun({ status, stdout }));
});
assert.equal(result.status, 0);
assert.match(result.stdout, /RESOLVED_CONTEXT:/);
assert.match(result.stdout, /# Jerv Pi product/);
assert.match(result.stdout, /UPDATE_AVAILABLE/);
} finally {
srv.close();
}
});
});
+214 -1
View File
@@ -1,5 +1,6 @@
import { describe, test, expect, afterEach } from 'bun:test';
import { launchBrowser } from '../cli/engine/engines/browser/detect-url.mjs';
import http from 'node:http';
import { launchBrowser, detectUrl, splitScanUrl } from '../cli/engine/engines/browser/detect-url.mjs';
// launchBrowser prefers the system-installed Chrome on Windows to dodge the
// bundled-Chrome GPU crash-loop (issue #372), and keeps the pinned bundled
@@ -79,3 +80,215 @@ describe('launchBrowser', () => {
expect(p.calls.every(c => c.channel === undefined)).toBe(true);
});
});
describe('splitScanUrl', () => {
test('strips http(s) userinfo and returns credentials', () => {
expect(splitScanUrl('https://user:pass@example.com')).toEqual({
href: 'https://example.com/',
credentials: { username: 'user', password: 'pass' },
});
expect(splitScanUrl('https://user:p%40ss@example.com/path?q=1')).toEqual({
href: 'https://example.com/path?q=1',
credentials: { username: 'user', password: 'p@ss' },
});
expect(splitScanUrl('https://user@example.com')).toEqual({
href: 'https://example.com/',
credentials: { username: 'user', password: '' },
});
expect(splitScanUrl('http://:secret@host.com/')).toEqual({
href: 'http://host.com/',
credentials: { username: '', password: 'secret' },
});
});
test('preserves original string when no userinfo', () => {
expect(splitScanUrl('https://example.com')).toEqual({
href: 'https://example.com',
credentials: null,
});
expect(splitScanUrl('https://example.com/path?email=a@b.com')).toEqual({
href: 'https://example.com/path?email=a@b.com',
credentials: null,
});
});
test('handles IPv6 and non-http(s) URLs', () => {
expect(splitScanUrl('https://user:pass@[::1]:8080/x')).toEqual({
href: 'https://[::1]:8080/x',
credentials: { username: 'user', password: 'pass' },
});
expect(splitScanUrl('file:///tmp/a.html')).toEqual({
href: 'file:///tmp/a.html',
credentials: null,
});
});
test('returns original string for invalid URLs', () => {
expect(splitScanUrl('not a url')).toEqual({
href: 'not a url',
credentials: null,
});
});
});
function makeFakeBrowser() {
const calls = { intercept: false, requestHandler: null, authenticate: [], goto: [] };
const page = {
on(event, handler) {
if (event === 'request') calls.requestHandler = handler;
},
async setViewport() {},
async setRequestInterception() { calls.intercept = true; },
async authenticate(creds) { calls.authenticate.push(creds); },
async goto(url, opts) { calls.goto.push({ url, opts }); },
async evaluate(fn) {
if (typeof fn === 'function' && fn.toString().includes('impeccableDetect')) {
return [{ findings: [{ type: 'low-contrast', detail: 'x', ignoreValue: '', severity: '' }] }];
}
return [];
},
async close() {},
};
return {
calls,
browser: {
async newPage() { return page; },
},
};
}
function fakeRequest(url, calls) {
return {
url: () => url,
headers: () => ({ accept: 'text/html' }),
continue(overrides) {
calls.continues.push({ url, overrides });
return Promise.resolve();
},
};
}
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve(`http://127.0.0.1:${server.address().port}/`);
});
});
}
describe('detectUrl credential redaction', () => {
test('scopes Authorization to the scan origin and redacts findings', async () => {
const { calls, browser } = makeFakeBrowser();
calls.continues = [];
const findings = await detectUrl('https://user:p%40ss@example.com/path', {
browser,
visualContrast: false,
contentHidden: false,
});
expect(calls.authenticate).toEqual([]);
expect(calls.intercept).toBe(true);
expect(typeof calls.requestHandler).toBe('function');
expect(calls.goto).toHaveLength(1);
expect(calls.goto[0].url).toBe('https://example.com/path');
const expected = `Basic ${Buffer.from('user:p@ss').toString('base64')}`;
await calls.requestHandler(fakeRequest('https://example.com/path', calls));
await calls.requestHandler(fakeRequest('https://evil.example/steal', calls));
expect(calls.continues[0].overrides.headers.authorization).toBe(expected);
expect(calls.continues[1].overrides).toBeUndefined();
expect(findings.length).toBeGreaterThan(0);
for (const f of findings) {
expect(f.file).toBe('https://example.com/path');
}
});
test('does not intercept when URL has no userinfo', async () => {
const { calls, browser } = makeFakeBrowser();
const url = 'https://example.com/path';
const findings = await detectUrl(url, {
browser,
visualContrast: false,
contentHidden: false,
});
expect(calls.authenticate).toEqual([]);
expect(calls.intercept).toBe(false);
expect(calls.requestHandler).toBe(null);
expect(findings.length).toBeGreaterThan(0);
for (const f of findings) {
expect(f.file).toBe(url);
}
});
});
describe('detectUrl origin-scoped basic auth', () => {
test('does not send URL credentials to a cross-origin redirect that challenges', async () => {
const user = 'qa-scanner';
const pass = 'Hunter2-657-SHOULD-NOT-LEAK';
const expected = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
const seenOnB = [];
const serverB = http.createServer((req, res) => {
seenOnB.push(req.headers.authorization || '');
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="b"' });
res.end('b');
});
const urlB = await listen(serverB);
const serverA = http.createServer((req, res) => {
res.writeHead(302, { Location: urlB });
res.end();
});
const urlA = await listen(serverA);
try {
try {
await detectUrl(urlA.replace('http://', `http://${user}:${pass}@`), {
visualContrast: false,
contentHidden: false,
waitUntil: 'domcontentloaded',
});
} catch {
// B's 401 may fail navigation once credentials are withheld.
}
expect(seenOnB.includes(expected)).toBe(false);
} finally {
await Promise.all([
new Promise((resolve) => serverA.close(resolve)),
new Promise((resolve) => serverB.close(resolve)),
]);
}
}, { timeout: 30000 });
test('still authenticates the original scan origin', async () => {
const user = 'qa-scanner';
const pass = 'Hunter2-657-SHOULD-NOT-LEAK';
const expected = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
const seen = [];
const server = http.createServer((req, res) => {
seen.push(req.headers.authorization || '');
if (req.headers.authorization !== expected) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="a"' });
res.end('no');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<!doctype html><html><body><h1>ok</h1></body></html>');
});
const origin = await listen(server);
try {
await detectUrl(origin.replace('http://', `http://${user}:${pass}@`), {
visualContrast: false,
contentHidden: false,
waitUntil: 'domcontentloaded',
});
expect(seen.includes(expected)).toBe(true);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}, { timeout: 30000 });
});