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