mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daae1d4117 | ||
|
|
2b88aa5231 | ||
|
|
fcd7622cd2 | ||
|
|
356b761391 | ||
|
|
1159100c96 | ||
|
|
0e9b6f9884 | ||
|
|
47e411952b | ||
|
|
5d7c1cce34 | ||
|
|
043e8a5bfd | ||
|
|
e975bec412 | ||
|
|
91f2c7b47e | ||
|
|
dca8f1ca6f |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLinkedCssPath(fileDir, href) {
|
||||
const stripped = href.split(/[?#]/)[0];
|
||||
const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//');
|
||||
if (!rootRelative) return path.resolve(fileDir, stripped);
|
||||
// Drop "." and reject ".." so /../outside.css cannot walk out of dir.
|
||||
const segments = stripped.replace(/^\/+/, '').split(/[/\\]/).filter(p => p && p !== '.');
|
||||
if (segments.some(p => p === '..')) return path.join(fileDir, segments.filter(p => p !== '..').join(path.sep));
|
||||
const rel = segments.join(path.sep);
|
||||
let dir = fileDir;
|
||||
for (;;) {
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break; // never use the filesystem root as document root
|
||||
try {
|
||||
const candidate = path.join(dir, rel);
|
||||
if (fs.statSync(candidate).isFile()) return candidate;
|
||||
} catch { /* missing or unreadable candidate */ }
|
||||
// Stop at the project root so a coincidental ~/static/app.css cannot win.
|
||||
try {
|
||||
if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) break;
|
||||
} catch { /* unreadable marker */ }
|
||||
dir = parent;
|
||||
}
|
||||
return path.join(fileDir, rel);
|
||||
}
|
||||
|
||||
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
const styleTexts = [];
|
||||
const warnedMissingStylesheets = new Set();
|
||||
for (const styleEl of modules.selectAll('style', root.children || [])) {
|
||||
styleTexts.push(modules.domutils.textContent(styleEl));
|
||||
}
|
||||
@@ -974,10 +1000,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
const rel = link.attribs?.rel || '';
|
||||
const href = link.attribs?.href || '';
|
||||
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
|
||||
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
|
||||
// literal path with the query in it; a versioned link otherwise made the
|
||||
// whole stylesheet invisible to every element-level check.
|
||||
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
|
||||
// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
|
||||
// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
|
||||
// invisible to every element-level check.
|
||||
const cssPath = resolveLinkedCssPath(fileDir, href);
|
||||
try {
|
||||
const css = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -987,7 +1013,14 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
detail: href,
|
||||
}, () => fs.readFileSync(cssPath, 'utf-8'));
|
||||
styleTexts.push(css);
|
||||
} catch { /* skip unreadable */ }
|
||||
} catch {
|
||||
if (!warnedMissingStylesheets.has(cssPath)) {
|
||||
warnedMissingStylesheets.add(cssPath);
|
||||
process.stderr.write(
|
||||
`impeccable detect: could not read linked stylesheet ${href} (resolved to ${cssPath}); color and custom-property rules will be incomplete\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return styleTexts.join('\n');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export const SUITES = {
|
||||
'tests/detect-antipatterns-fixtures.test.mjs',
|
||||
'tests/detect-antipatterns-browser.test.mjs',
|
||||
'tests/detect-cli-design-contamination.test.mjs',
|
||||
'tests/detect-cli-design-monorepo.test.mjs',
|
||||
'tests/detect-cli-stdin-dispatch.test.mjs',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1321,6 +1321,81 @@ describe('detectHtml — static HTML/CSS engine', () => {
|
||||
expect(findingIds(f)).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('resolves root-relative linked stylesheets with cache-busting query', async () => {
|
||||
await withStaticFixture({
|
||||
'index.html': `<!DOCTYPE html><html><head>
|
||||
<link rel="stylesheet" href="/static/app.css?v=3">
|
||||
</head><body><div class="card">Card</div></body></html>`,
|
||||
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
|
||||
}, async ({ file }) => {
|
||||
const f = await detectHtml(file);
|
||||
expect(findingIds(f)).toContain('side-tab');
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves root-relative linked stylesheets from nested pages via ancestor walk', async () => {
|
||||
await withStaticFixture({
|
||||
'pages/about.html': `<!DOCTYPE html><html><head>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head><body><div class="card">Card</div></body></html>`,
|
||||
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
|
||||
}, async ({ dir }) => {
|
||||
const f = await detectHtml(path.join(dir, 'pages', 'about.html'));
|
||||
expect(findingIds(f)).toContain('side-tab');
|
||||
});
|
||||
});
|
||||
|
||||
test('does not resolve root-relative sheets above the project root', async () => {
|
||||
await withStaticFixture({
|
||||
'project/package.json': '{}',
|
||||
'project/index.html': `<!DOCTYPE html><html><head>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head><body><div class="card">Card</div></body></html>`,
|
||||
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
|
||||
}, async ({ dir }) => {
|
||||
const f = await detectHtml(path.join(dir, 'project', 'index.html'));
|
||||
expect(findingIds(f)).not.toContain('side-tab');
|
||||
});
|
||||
});
|
||||
|
||||
test('does not follow root-relative .. segments out of the page directory', async () => {
|
||||
await withStaticFixture({
|
||||
'project/package.json': '{}',
|
||||
'project/index.html': `<!DOCTYPE html><html><head>
|
||||
<link rel="stylesheet" href="/../outside.css">
|
||||
</head><body><div class="card">Card</div></body></html>`,
|
||||
'outside.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
|
||||
}, async ({ dir }) => {
|
||||
const f = await detectHtml(path.join(dir, 'project', 'index.html'));
|
||||
expect(findingIds(f)).not.toContain('side-tab');
|
||||
});
|
||||
});
|
||||
|
||||
test('warns when a linked stylesheet cannot be read', async () => {
|
||||
const writes = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk, ...args) => {
|
||||
writes.push(String(chunk));
|
||||
return origWrite(chunk, ...args);
|
||||
};
|
||||
try {
|
||||
await withStaticFixture({
|
||||
'index.html': `<!DOCTYPE html><html><head>
|
||||
<link rel="stylesheet" href="/missing/app.css">
|
||||
</head><body><div>Page</div></body></html>`,
|
||||
}, async ({ file, dir }) => {
|
||||
await detectHtml(file);
|
||||
await detectHtml(file);
|
||||
const msg = writes.join('');
|
||||
const hits = msg.split('could not read linked stylesheet /missing/app.css').length - 1;
|
||||
expect(hits).toBe(2);
|
||||
expect(msg).toContain(`resolved to ${path.join(dir, 'missing', 'app.css')}`);
|
||||
});
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
}
|
||||
});
|
||||
|
||||
test('gradient-text: a style="" attribute alone carries the page-level flag', async () => {
|
||||
await withStaticFixture({
|
||||
'index.html': `<!DOCTYPE html><html><head><title>t</title></head><body>
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* Regression for issue #570: design-system rules must reach a monorepo workspace
|
||||
* by inheriting the repo root's DESIGN.md.
|
||||
*
|
||||
* Run with: node --test tests/detect-cli-design-monorepo.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { findDesignRoot } from '../cli/engine/design-system.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = path.resolve(__dirname, '../cli/bin/cli.js');
|
||||
|
||||
const PAGE_HTML =
|
||||
'<!doctype html><html><head><style>.card { font-family: Verdana, sans-serif; }</style></head>' +
|
||||
'<body><div class="card">Hi</div></body></html>';
|
||||
|
||||
const DESIGN_MD = `---
|
||||
typography:
|
||||
body:
|
||||
fontFamily: "Palatino, Georgia, serif"
|
||||
---
|
||||
# Project A Design System
|
||||
`;
|
||||
|
||||
const tempRoots = [];
|
||||
|
||||
function runDetect(cwd, targets, env = {}) {
|
||||
const result = spawnSync(process.execPath, [CLI, 'detect', '--json', ...targets], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
let findings = [];
|
||||
try {
|
||||
findings = JSON.parse(result.stdout || '[]');
|
||||
} catch {
|
||||
throw new Error(`Non-JSON CLI output.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function fontFindingsFor(findings, file) {
|
||||
return findings.filter(
|
||||
(f) => f.antipattern === 'design-system-font' && (!file || f.file === file),
|
||||
);
|
||||
}
|
||||
|
||||
function mkPnpmMonorepo({ workspaceDesign = null } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-pnpm-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const page = path.join(dir, 'apps/web/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
if (workspaceDesign) {
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/DESIGN.md'), workspaceDesign);
|
||||
}
|
||||
return { dir, page, webDir: path.join(dir, 'apps/web') };
|
||||
}
|
||||
|
||||
function mkTempRoot(prefix) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempRoots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
after(() => {
|
||||
for (const dir of tempRoots) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
describe('detect CLI monorepo DESIGN.md inheritance', () => {
|
||||
it('pnpm workspace root: workspace page inherits root DESIGN.md', () => {
|
||||
const { dir, page } = mkPnpmMonorepo();
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'Verdana must be flagged via inherited root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('npm/yarn workspaces root: workspace page inherits root DESIGN.md', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-npm-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono","workspaces":["packages/*"]}');
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}');
|
||||
const page = path.join(dir, 'packages/ui/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'Verdana must be flagged via inherited root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('turbo marker root: workspace page inherits root DESIGN.md', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-turbo-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono"}');
|
||||
fs.writeFileSync(path.join(dir, 'turbo.json'), '{}');
|
||||
fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const page = path.join(dir, 'apps/web/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'Verdana must be flagged via inherited root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('lerna packages globs: workspace outside apps/packages inherits root DESIGN.md', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-lerna-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'lerna.json'), '{"packages":["modules/*"]}');
|
||||
fs.mkdirSync(path.join(dir, 'modules/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'modules/web/package.json'), '{"name":"web"}');
|
||||
const page = path.join(dir, 'modules/web/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'lerna packages globs must be read as workspace declarations',
|
||||
);
|
||||
});
|
||||
|
||||
it('impeccable projectRoots: workspace inherits root DESIGN.md', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-iroots-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.mkdirSync(path.join(dir, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.impeccable/config.json'), '{"projectRoots":["sites/*"]}');
|
||||
fs.mkdirSync(path.join(dir, 'sites/docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'sites/docs/package.json'), '{"name":"docs"}');
|
||||
const page = path.join(dir, 'sites/docs/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'impeccable projectRoots must be read as workspace declarations',
|
||||
);
|
||||
});
|
||||
|
||||
it('pnpm flow list with inline comment and non-standard dirs still detected', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-flow-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'packages: ["services/*"] # deploy targets\n');
|
||||
fs.mkdirSync(path.join(dir, 'services/api'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'services/api/package.json'), '{"name":"api"}');
|
||||
const page = path.join(dir, 'services/api/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'inline YAML comment must not defeat workspace-glob recognition',
|
||||
);
|
||||
});
|
||||
|
||||
it('directory target: scan apps/web dir inherits root DESIGN.md', () => {
|
||||
const { dir, page, webDir } = mkPnpmMonorepo();
|
||||
const findings = runDetect(dir, [webDir]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'Verdana must be flagged when scanning the workspace directory',
|
||||
);
|
||||
});
|
||||
|
||||
it('workspace-owned DESIGN.md wins over monorepo root', () => {
|
||||
const workspaceDesign = `---
|
||||
typography:
|
||||
body:
|
||||
fontFamily: "Verdana, sans-serif"
|
||||
---
|
||||
# Workspace Design System
|
||||
`;
|
||||
const { dir, page } = mkPnpmMonorepo({ workspaceDesign });
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length,
|
||||
0,
|
||||
'workspace DESIGN.md allowing Verdana must suppress inherited root rules',
|
||||
);
|
||||
});
|
||||
|
||||
it('nested separate repo inherits nothing from monorepo root', () => {
|
||||
const { dir } = mkPnpmMonorepo();
|
||||
fs.mkdirSync(path.join(dir, 'vendor/other/.git'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'vendor/other/package.json'), '{"name":"other"}');
|
||||
const page = path.join(dir, 'vendor/other/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length,
|
||||
0,
|
||||
'nested repo with no workspaces must not inherit monorepo root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('home directory is never an owning monorepo root', () => {
|
||||
// context.mjs's findMonorepoRoot stops at homeDir before its monorepo
|
||||
// check; the engine walk must match, or a workspace-declaring $HOME
|
||||
// leaks its DESIGN.md into every git-less project beneath it.
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-home-'));
|
||||
tempRoots.push(home);
|
||||
fs.writeFileSync(path.join(home, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(home, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(home, 'project'), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, 'project/package.json'), '{"name":"p"}');
|
||||
const page = path.join(home, 'project/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(home, [page], { HOME: home, USERPROFILE: home });
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length,
|
||||
0,
|
||||
'a project under a workspace-declaring $HOME must not inherit its DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('symlinked $HOME still stops the walk', () => {
|
||||
// Some distros symlink home paths (/home -> /var/home), so $HOME never
|
||||
// string-matches the physical paths a cwd-resolved target produces. The
|
||||
// walk must compare against the realpath form too.
|
||||
const real = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-realhome-'));
|
||||
tempRoots.push(real);
|
||||
const link = path.join(os.tmpdir(), `impeccable-detect-mono-linkhome-${path.basename(real).slice(-6)}`);
|
||||
fs.symlinkSync(real, link);
|
||||
tempRoots.push(link);
|
||||
fs.writeFileSync(path.join(real, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(real, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(real, 'project'), { recursive: true });
|
||||
fs.writeFileSync(path.join(real, 'project/package.json'), '{"name":"p"}');
|
||||
const page = path.join(real, 'project/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
// HOME is the symlink; the target is passed via its physical path, so a
|
||||
// logical-only comparison would walk straight past home and inherit.
|
||||
const findings = runDetect(real, [fs.realpathSync(page)], { HOME: link, USERPROFILE: link });
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length + fontFindingsFor(findings, fs.realpathSync(page)).length,
|
||||
0,
|
||||
'a symlinked $HOME must still stop the walk before inheriting',
|
||||
);
|
||||
});
|
||||
|
||||
it('CSS module at apps/web/app/page.module.css inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-cssmod-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(dir, 'apps/web/app'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const css = path.join(dir, 'apps/web/app/page.module.css');
|
||||
fs.writeFileSync(css, '.c{font-family:Verdana,sans-serif}');
|
||||
|
||||
const findings = runDetect(dir, [css]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, css).some((f) => f.ignoreValue?.toLowerCase() === 'verdana'),
|
||||
'Verdana in a CSS module must be flagged via inherited root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('yarn workspaces object form: workspace inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-yarnobj-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: { packages: ['packages/*'] },
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}');
|
||||
const page = path.join(dir, 'packages/ui/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'yarn workspaces object form must inherit root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('nx.json marker root: workspace inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-nx-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono"}');
|
||||
fs.writeFileSync(path.join(dir, 'nx.json'), '{}');
|
||||
fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const page = path.join(dir, 'apps/web/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'nx.json marker root must inherit root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('file at monorepo root still flags against root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-rootfile-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
const page = path.join(dir, 'page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'root-level file must be judged against root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('scan from different cwd still inherits via target path', () => {
|
||||
const { page } = mkPnpmMonorepo();
|
||||
const otherCwd = mkTempRoot('impeccable-detect-mono-othercwd-');
|
||||
|
||||
const findings = runDetect(otherCwd, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'resolution must follow the target path, not process.cwd()',
|
||||
);
|
||||
});
|
||||
|
||||
it('findDesignRoot(apps/web) returns monorepo root with hasDesign true', () => {
|
||||
const { dir, webDir } = mkPnpmMonorepo();
|
||||
const found = findDesignRoot(webDir);
|
||||
assert.equal(found.dir, dir);
|
||||
assert.equal(found.hasDesign, true);
|
||||
});
|
||||
|
||||
it('negated workspace package does not inherit root DESIGN.md (Greptile P1)', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-negated-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: ['packages/*', '!packages/excluded'],
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'packages/included'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/included/package.json'), '{"name":"included"}');
|
||||
const includedPage = path.join(dir, 'packages/included/page.html');
|
||||
fs.writeFileSync(includedPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'packages/excluded'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/excluded/package.json'), '{"name":"excluded"}');
|
||||
const excludedPage = path.join(dir, 'packages/excluded/page.html');
|
||||
fs.writeFileSync(excludedPage, PAGE_HTML);
|
||||
const excludedDir = path.join(dir, 'packages/excluded');
|
||||
|
||||
const findings = runDetect(dir, [includedPage, excludedPage]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, includedPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'included workspace package must inherit root DESIGN.md',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, excludedPage).length,
|
||||
0,
|
||||
'negated workspace package must not inherit root DESIGN.md',
|
||||
);
|
||||
const excludedRoot = findDesignRoot(excludedDir);
|
||||
assert.equal(excludedRoot.dir, excludedDir);
|
||||
assert.equal(excludedRoot.hasDesign, false);
|
||||
});
|
||||
|
||||
it('stray nested package outside globs does not inherit root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-stray-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const webPage = path.join(dir, 'apps/web/page.html');
|
||||
fs.writeFileSync(webPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'vendor/tool'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'vendor/tool/package.json'), '{"name":"tool"}');
|
||||
const vendorPage = path.join(dir, 'vendor/tool/page.html');
|
||||
fs.writeFileSync(vendorPage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [webPage, vendorPage]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, webPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'apps/web must inherit root DESIGN.md',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, vendorPage).length,
|
||||
0,
|
||||
'vendor/tool outside globs must not inherit root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('non-monorepo nested package.json does not inherit root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-nestedpkg-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"root"}');
|
||||
fs.mkdirSync(path.join(dir, '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'packages/nested'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/nested/package.json'), '{"name":"nested"}');
|
||||
const nestedPage = path.join(dir, 'packages/nested/page.html');
|
||||
fs.writeFileSync(nestedPage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [nestedPage]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, nestedPage).length,
|
||||
0,
|
||||
'nested package.json in a non-monorepo must not inherit root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('non-monorepo without DESIGN.md: no design-system-font findings', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-nodesign-');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"root"}');
|
||||
const page = path.join(dir, 'page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length,
|
||||
0,
|
||||
'no DESIGN.md means no design-system-font findings',
|
||||
);
|
||||
});
|
||||
|
||||
it('single-package repo: src/page.html inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-single-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app"}');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
const page = path.join(dir, 'src/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'src/ without its own package.json must inherit project DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('DESIGN.md in docs/ fallback still flags in single-package repo', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-docsfb-');
|
||||
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'docs/DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app"}');
|
||||
const page = path.join(dir, 'src/page.html');
|
||||
fs.mkdirSync(path.dirname(page), { recursive: true });
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'docs/DESIGN.md fallback must apply to nested files',
|
||||
);
|
||||
});
|
||||
|
||||
it('pnpm !**/test/** does not smash sibling workspaces', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-globstar-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), [
|
||||
'packages:',
|
||||
" - 'packages/*'",
|
||||
" - 'components/**'",
|
||||
" - '!**/test/**'",
|
||||
'',
|
||||
].join('\n'));
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}');
|
||||
const uiPage = path.join(dir, 'packages/ui/page.html');
|
||||
fs.writeFileSync(uiPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'components/button'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'components/button/package.json'), '{"name":"button"}');
|
||||
const buttonPage = path.join(dir, 'components/button/page.html');
|
||||
fs.writeFileSync(buttonPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui/test/fixture'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/test/fixture/package.json'), '{"name":"fixture"}');
|
||||
const testPage = path.join(dir, 'packages/ui/test/fixture/page.html');
|
||||
fs.writeFileSync(testPage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [uiPage, buttonPage, testPage]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, uiPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'packages/ui must still inherit when a globstar test exclusion is present',
|
||||
);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, buttonPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'components/** must still inherit when a globstar test exclusion is present',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, testPage).length,
|
||||
0,
|
||||
'packages/ui/test/fixture must not inherit under !**/test/**',
|
||||
);
|
||||
});
|
||||
|
||||
it('workspaces ["*"] owns only direct children, not vendor/tool', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-star-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: ['*'],
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'web/package.json'), '{"name":"web"}');
|
||||
const webPage = path.join(dir, 'web/page.html');
|
||||
fs.writeFileSync(webPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'web/examples'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'web/examples/package.json'), '{"name":"examples"}');
|
||||
const nestedPage = path.join(dir, 'web/examples/page.html');
|
||||
fs.writeFileSync(nestedPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'vendor/tool'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'vendor/tool/package.json'), '{"name":"tool"}');
|
||||
const vendorPage = path.join(dir, 'vendor/tool/page.html');
|
||||
fs.writeFileSync(vendorPage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [webPage, nestedPage, vendorPage]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, webPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'direct-child workspace under * must inherit root DESIGN.md',
|
||||
);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, nestedPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'nested package under a * workspace child must still inherit',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, vendorPage).length,
|
||||
0,
|
||||
'vendor/tool is not a direct child of * and must not inherit',
|
||||
);
|
||||
});
|
||||
|
||||
it('nested package under an included workspace inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-nestedws-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: ['packages/*'],
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}');
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui/examples'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/examples/package.json'), '{"name":"examples"}');
|
||||
const page = path.join(dir, 'packages/ui/examples/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'packages/ui/examples must inherit as nested content of packages/*',
|
||||
);
|
||||
const found = findDesignRoot(path.join(dir, 'packages/ui/examples'));
|
||||
assert.equal(found.dir, dir);
|
||||
assert.equal(found.hasDesign, true);
|
||||
});
|
||||
|
||||
it('impeccable projectRoots beat a package-manager negation of the same path', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-iroots-win-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.mkdirSync(path.join(dir, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.impeccable/config.json'), '{"projectRoots":["sites/*"]}');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: ['sites/*', '!sites/docs'],
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'sites/docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'sites/docs/package.json'), '{"name":"docs"}');
|
||||
const page = path.join(dir, 'sites/docs/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'projectRoots must govern a path they match even when workspaces exclude it',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user