From 4b70ba4c41968788311723b0c39d3b1abc76e492 Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Fri, 24 Apr 2026 22:00:32 -0700 Subject: [PATCH 1/3] feat(config): multi-folder paths for components, primitives, composites, rules (#1420) Path fields in RaftersConfig now accept either a single string (status quo) or an array of entries to support multi-folder layouts. Unlocks @shingle/shared: each shingle site can declare its own composites folder plus the shared package without copy-paste, while per-site token overrides stay local. Schema: componentsPath / primitivesPath / compositesPath / rulesPath: string | (string | { path: string; root?: true })[] Resolution: 1. Entry tagged { root: true } wins as install root 2. Else first entry whose realpath resolves inside cwd 3. Else framework default at cwd Local always wins on collision. resolveReadSet puts the install root first in the returned array, so first-write-wins loaders produce local-wins reads. New helpers in utils/paths.ts: resolveRoot, resolveReadSet. MCP composite loader reads the resolved set instead of hardcoded .rafters/composites. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/CHANGELOG.md | 6 ++ packages/cli/src/commands/add.ts | 84 ++++++++++---------- packages/cli/src/commands/init.ts | 63 +++++++++------ packages/cli/src/mcp/tools.ts | 31 +++++++- packages/cli/src/utils/paths.ts | 109 +++++++++++++++++++++++++- packages/cli/test/utils/paths.test.ts | 81 ++++++++++++++++++- 6 files changed, 302 insertions(+), 72 deletions(-) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 9e391a26..7896322e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # rafters +## Unreleased + +### Minor Changes + +- feat(config): multi-folder paths for components, primitives, composites, rules (#1420). Each path field in `.rafters/config.rafters.json` now accepts either the existing single string or an array of entries, so a project can read assets from external folders (e.g. `@shingle/shared`) on top of its own. Entries are plain strings or `{ path, root: true }` objects; the install root for `rafters add` is the entry tagged `{ root: true }`, otherwise the first entry whose realpath resolves inside cwd, otherwise the framework default at cwd. Local always wins on collision -- the install root is always first in the read set so first-write-wins semantics in loaders produce local-wins reads. New `rulesPath` field added with the same semantics. New helpers `resolveRoot` and `resolveReadSet` in `utils/paths.ts`. MCP composite loader now iterates the resolved read set instead of a hardcoded `.rafters/composites` path, so shared composite packages are queryable through `rafters_composite`. + ## 0.0.53 ### Minor Changes diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 39db085c..331a2b57 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -20,7 +20,7 @@ import { type InstallRegistryDepsResult, installRegistryDependencies, } from '../utils/install-registry-deps.js'; -import { getRaftersPaths } from '../utils/paths.js'; +import { getRaftersPaths, type PathField, resolveRoot } from '../utils/paths.js'; import { error, log, setAgentMode } from '../utils/ui.js'; import type { RaftersConfig } from './init.js'; @@ -170,53 +170,53 @@ export function isAlreadyInstalled(config: RaftersConfig | null, item: RegistryI */ export function trackInstalled(config: RaftersConfig, items: RegistryItem[]): void { if (!config.installed) { - config.installed = { components: [], primitives: [], composites: [] }; - } - if (!config.installed.composites) { - config.installed.composites = []; + config.installed = { components: [], primitives: [], composites: [], rules: [] }; } + const installed = config.installed; + if (!installed.composites) installed.composites = []; + if (!installed.rules) installed.rules = []; for (const item of items) { - if (item.type === 'ui') { - if (!config.installed.components.includes(item.name)) { - config.installed.components.push(item.name); - } - } else if (item.type === 'composite') { - if (!config.installed.composites.includes(item.name)) { - config.installed.composites.push(item.name); - } - } else { - if (!config.installed.primitives.includes(item.name)) { - config.installed.primitives.push(item.name); - } - } + const bucket = + item.type === 'ui' + ? installed.components + : item.type === 'composite' + ? installed.composites + : installed.primitives; + if (!bucket.includes(item.name)) bucket.push(item.name); } - config.installed.components.sort(); - config.installed.primitives.sort(); - config.installed.composites.sort(); + installed.components.sort(); + installed.primitives.sort(); + installed.composites.sort(); + installed.rules.sort(); +} + +/** + * Resolve the install root for a config path field. Path fields accept a + * single string or an array of entries; this returns the relative folder + * `rafters add` should write into. See {@link resolveRoot} for precedence. + */ +function rootFor(field: PathField | undefined, cwd: string, fallback: string): string { + return field === undefined ? fallback : resolveRoot(field, cwd, fallback); } /** * Transform registry path to project path based on config * e.g., "components/ui/button.tsx" -> "app/components/ui/button.tsx" */ -function transformPath(registryPath: string, config: RaftersConfig | null): string { +function transformPath(registryPath: string, config: RaftersConfig | null, cwd: string): string { if (!config) return registryPath; - // Transform component paths - if (registryPath.startsWith('components/ui/')) { - return registryPath.replace('components/ui/', `${config.componentsPath}/`); - } - - // Transform primitive paths - if (registryPath.startsWith('lib/primitives/')) { - return registryPath.replace('lib/primitives/', `${config.primitivesPath}/`); - } - - // Transform composite paths - if (registryPath.startsWith('composites/')) { - return registryPath.replace('composites/', `${config.compositesPath}/`); + const replacements: Array<[string, PathField, string]> = [ + ['components/ui/', config.componentsPath, 'components/ui'], + ['lib/primitives/', config.primitivesPath, 'lib/primitives'], + ['composites/', config.compositesPath, 'composites'], + ['rules/', config.rulesPath, 'rules'], + ]; + for (const [prefix, field, fallback] of replacements) { + if (registryPath.startsWith(prefix)) { + return registryPath.replace(prefix, `${rootFor(field, cwd, fallback)}/`); + } } - return registryPath; } @@ -240,12 +240,13 @@ export function transformFileContent( content: string, config: RaftersConfig | null, fileType: 'component' | 'primitive' = 'component', + cwd: string = process.cwd(), ): string { let transformed = content; // Get paths from config or use defaults - const componentsPath = config?.componentsPath ?? 'components/ui'; - const primitivesPath = config?.primitivesPath ?? 'lib/primitives'; + const componentsPath = rootFor(config?.componentsPath, cwd, 'components/ui'); + const primitivesPath = rootFor(config?.primitivesPath, cwd, 'lib/primitives'); // Strip source root prefix (src/, app/) for @/ alias imports. // Config paths are filesystem paths (src/components/ui) but @/ alias @@ -329,7 +330,7 @@ async function installItem( for (const file of filesToInstall) { // Transform the path based on project config - const projectPath = transformPath(file.path, config); + const projectPath = transformPath(file.path, config, cwd); const targetPath = join(cwd, projectPath); // Check if file exists and handle overwrite @@ -351,7 +352,7 @@ async function installItem( // Transform and write the file const fileType = item.type === 'primitive' ? 'primitive' : 'component'; - const transformedContent = transformFileContent(file.content, config, fileType); + const transformedContent = transformFileContent(file.content, config, fileType, cwd); await writeFile(targetPath, transformedContent, 'utf-8'); installedFiles.push(projectPath); @@ -646,10 +647,11 @@ export async function add(componentArgs: string[], options: AddOptions): Promise componentsPath: 'components/ui', primitivesPath: 'lib/primitives', compositesPath: 'composites', + rulesPath: 'rules', cssPath: null, shadcn: false, exports: DEFAULT_EXPORTS, - installed: { components: [], primitives: [], composites: [] }, + installed: { components: [], primitives: [], composites: [], rules: [] }, }; trackInstalled(newConfig, installedItems); await saveConfig(cwd, newConfig); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1a7e196b..7fc539c8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -39,7 +39,7 @@ import { FUTURE_EXPORTS, selectionsToConfig, } from '../utils/exports.js'; -import { getRaftersPaths } from '../utils/paths.js'; +import { getRaftersPaths, type PathField } from '../utils/paths.js'; import { isAgentMode, log, setAgentMode } from '../utils/ui.js'; import { updateDependencies } from '../utils/update-dependencies.js'; @@ -83,42 +83,58 @@ const CSS_LOCATIONS: Record = { }; // Default component paths per framework -const COMPONENT_PATHS: Record< +export const COMPONENT_PATHS: Record< Framework, - { components: string; primitives: string; composites: string } + { components: string; primitives: string; composites: string; rules: string } > = { astro: { components: 'src/components/ui', primitives: 'src/lib/primitives', composites: 'src/composites', + rules: 'src/rules', + }, + next: { + components: 'components/ui', + primitives: 'lib/primitives', + composites: 'composites', + rules: 'rules', }, - next: { components: 'components/ui', primitives: 'lib/primitives', composites: 'composites' }, vite: { components: 'src/components/ui', primitives: 'src/lib/primitives', composites: 'src/composites', + rules: 'src/rules', }, remix: { components: 'app/components/ui', primitives: 'app/lib/primitives', composites: 'app/composites', + rules: 'app/rules', }, 'react-router': { components: 'app/components/ui', primitives: 'app/lib/primitives', composites: 'app/composites', + rules: 'app/rules', }, wc: { components: 'src/components/ui', primitives: 'src/lib/primitives', composites: 'src/composites', + rules: 'src/rules', }, vanilla: { components: 'src/components/ui', primitives: 'src/lib/primitives', composites: 'src/composites', + rules: 'src/rules', + }, + unknown: { + components: 'components/ui', + primitives: 'lib/primitives', + composites: 'composites', + rules: 'rules', }, - unknown: { components: 'components/ui', primitives: 'lib/primitives', composites: 'composites' }, }; /** @@ -136,34 +152,29 @@ const FRAMEWORK_PROMPT_LABELS: Record, string> = { /** * Configuration persisted in `.rafters/config.rafters.json`. - * Used by the CLI to resolve framework-specific defaults and perform - * path transformations when generating or updating files. - * All paths are relative to the project root. + * + * Path fields accept either a single string (status quo) or an array of + * entries to support multi-folder layouts (e.g. project + `@shingle/shared`). + * When multiple entries are provided, the install root is the entry tagged + * `{ root: true }`, otherwise the first entry whose realpath resolves inside + * cwd. Local entries always win on collision. */ export interface RaftersConfig { - /** Detected or selected application framework */ framework: Framework; - /** Which file variant to install: react (.tsx), astro (.astro), vue (.vue), svelte (.svelte) */ componentTarget?: ComponentTarget; - /** Root directory for UI components, e.g. `components/ui` or `app/components/ui` */ - componentsPath: string; - /** Root directory for primitive components, e.g. `lib/primitives` */ - primitivesPath: string; - /** Root directory for composite JSON files, e.g. `src/composites` */ - compositesPath: string; - /** Entry CSS file for design tokens, or null if not detected */ + componentsPath: PathField; + primitivesPath: PathField; + compositesPath: PathField; + rulesPath: PathField; cssPath: string | null; - /** Whether shadcn/ui was detected in the project */ shadcn: boolean; - /** Export format selections */ exports: ExportConfig; - /** Dark mode strategy: 'class' (default, .dark class toggle) or 'media' (OS preference) */ darkMode?: 'class' | 'media'; - /** Items installed via `rafters add` */ installed?: { components: string[]; primitives: string[]; composites: string[]; + rules: string[]; }; } @@ -416,6 +427,7 @@ async function regenerateFromExisting( existingConfig.componentsPath = frameworkPaths.components; existingConfig.primitivesPath = frameworkPaths.primitives; existingConfig.compositesPath = frameworkPaths.composites; + existingConfig.rulesPath = frameworkPaths.rules; } // Load all tokens from .rafters/tokens/ @@ -469,10 +481,11 @@ async function regenerateFromExisting( componentsPath: frameworkPaths.components, primitivesPath: frameworkPaths.primitives, compositesPath: frameworkPaths.composites, + rulesPath: frameworkPaths.rules, cssPath: null, shadcn: !!shadcn, exports, - installed: { components: [], primitives: [], composites: [] }, + installed: { components: [], primitives: [], composites: [], rules: [] }, }; await writeFile(paths.config, JSON.stringify(newConfig, null, 2)); } @@ -509,6 +522,7 @@ async function resetToDefaults( existingConfig.componentsPath = frameworkPaths.components; existingConfig.primitivesPath = frameworkPaths.primitives; existingConfig.compositesPath = frameworkPaths.composites; + existingConfig.rulesPath = frameworkPaths.rules; } // Load existing tokens to check for userOverride backups @@ -598,10 +612,11 @@ async function resetToDefaults( componentsPath: frameworkPaths.components, primitivesPath: frameworkPaths.primitives, compositesPath: frameworkPaths.composites, + rulesPath: frameworkPaths.rules, cssPath: null, shadcn: !!shadcn, exports, - installed: { components: [], primitives: [], composites: [] }, + installed: { components: [], primitives: [], composites: [], rules: [] }, }; await writeFile(paths.config, JSON.stringify(newConfig, null, 2)); } @@ -869,6 +884,7 @@ export async function init(options: InitOptions): Promise { componentsPath: frameworkPaths.components, primitivesPath: frameworkPaths.primitives, compositesPath: frameworkPaths.composites, + rulesPath: frameworkPaths.rules, cssPath: detectedCssPath, shadcn: !!shadcn, exports, @@ -876,6 +892,7 @@ export async function init(options: InitOptions): Promise { components: [], primitives: [], composites: [], + rules: [], }, }; await writeFile(paths.config, JSON.stringify(config, null, 2)); diff --git a/packages/cli/src/mcp/tools.ts b/packages/cli/src/mcp/tools.ts index 455cee4f..5c2c97ee 100644 --- a/packages/cli/src/mcp/tools.ts +++ b/packages/cli/src/mcp/tools.ts @@ -12,7 +12,7 @@ * Token import lives in `rafters init` / `rafters import`, not MCP. */ -import { readdir } from 'node:fs/promises'; +import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { @@ -24,8 +24,9 @@ import { registerComposite, searchComposites, } from '@rafters/composites'; +import type { RaftersConfig } from '../commands/init.js'; import { registryClient } from '../registry/client.js'; -import { getRaftersPaths } from '../utils/paths.js'; +import { getRaftersPaths, resolveReadSet } from '../utils/paths.js'; import { resolveWorkspace, type Workspace } from '../utils/workspaces.js'; const WORKSPACE_PARAM = { @@ -258,12 +259,34 @@ export class RaftersToolHandler { } if (workspace && !this.compositesLoadedFor.has(workspace.root)) { - const paths = getRaftersPaths(workspace.root); - await this.loadCompositesFromDir(join(paths.root, 'composites')); + for (const dir of await this.compositeReadRoots(workspace.root)) { + await this.loadCompositesFromDir(dir); + } this.compositesLoadedFor.add(workspace.root); } } + /** + * Resolve the set of folders to scan for composite manifests in a workspace. + * Reads `.rafters/config.rafters.json` and applies the workspace's + * `compositesPath` (which may be a string or an array of entries to support + * shared packages like `@shingle/shared`). Falls back to `.rafters/composites` + * when no config or compositesPath is set. + */ + private async compositeReadRoots(workspaceRoot: string): Promise { + const paths = getRaftersPaths(workspaceRoot); + let config: RaftersConfig | null = null; + try { + config = JSON.parse(await readFile(paths.config, 'utf-8')) as RaftersConfig; + } catch { + // No config -- fall through to default + } + if (!config?.compositesPath) { + return [join(paths.root, 'composites')]; + } + return resolveReadSet(config.compositesPath, workspaceRoot); + } + private async handleComposite(args: Record): Promise { const { id, query, category, workspace } = args as { id?: string; diff --git a/packages/cli/src/utils/paths.ts b/packages/cli/src/utils/paths.ts index 77257487..0cd05fb6 100644 --- a/packages/cli/src/utils/paths.ts +++ b/packages/cli/src/utils/paths.ts @@ -2,7 +2,9 @@ * Path utilities for .rafters/ directory structure */ -import { join } from 'node:path'; +import { existsSync, realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { z } from 'zod'; export interface RaftersPaths { root: string; @@ -42,3 +44,108 @@ export function getOutputFilePath( ): string { return join(projectRoot, '.rafters', 'output', filename); } + +/** + * A single entry in a path field. Either a plain path string, or an object + * with `root: true` to mark the install target explicitly. Used for fields + * that accept multiple folders (componentsPath, primitivesPath, compositesPath, + * rulesPath). + */ +export const PathEntrySchema = z.union([ + z.string(), + z.object({ path: z.string(), root: z.literal(true).optional() }), +]); + +export type PathEntry = z.infer; + +/** + * A path field accepts a single string (status quo) or an array of entries. + */ +export const PathFieldSchema = z.union([z.string(), z.array(PathEntrySchema)]); + +export type PathField = z.infer; + +function entryPath(entry: PathEntry): string { + return typeof entry === 'string' ? entry : entry.path; +} + +function entryHasExplicitRoot(entry: PathEntry): boolean { + return typeof entry === 'object' && entry.root === true; +} + +function tryRealpath(absPath: string): string { + try { + return realpathSync(absPath); + } catch { + return absPath; + } +} + +function isInsideCwd(absPath: string, cwdReal: string): boolean { + const rel = relative(cwdReal, absPath); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +/** + * Resolve the install root for a path field. + * + * Precedence: + * 1. The first entry with `{ root: true }` + * 2. The first entry whose realpath resolves inside cwd + * 3. `fallback` joined to cwd (the framework default) + * + * Returns a path **relative to cwd** so it can be persisted in config and + * compared with the other resolved paths consistently. + */ +export function resolveRoot(field: PathField, cwd: string, fallback: string): string { + const cwdReal = tryRealpath(resolve(cwd)); + + if (typeof field === 'string') { + return field; + } + + for (const entry of field) { + if (entryHasExplicitRoot(entry)) { + return entryPath(entry); + } + } + + for (const entry of field) { + const p = entryPath(entry); + const abs = isAbsolute(p) ? p : resolve(cwdReal, p); + if (existsSync(abs) && isInsideCwd(tryRealpath(abs), cwdReal)) { + return p; + } + if (!existsSync(abs) && isInsideCwd(resolve(cwdReal, p), cwdReal)) { + return p; + } + } + + return fallback; +} + +/** + * Resolve the read set for a path field: the ordered list of absolute roots + * to search when loading items. The install root (per {@link resolveRoot}) is + * always first, so first-write-wins semantics in the loader produce + * "local wins on collision". Realpathed and deduplicated. + */ +export function resolveReadSet(field: PathField, cwd: string, fallback?: string): string[] { + const cwdReal = tryRealpath(resolve(cwd)); + const entries = typeof field === 'string' ? [field] : field.map(entryPath); + const root = resolveRoot(field, cwd, fallback ?? entries[0] ?? ''); + const ordered = [root, ...entries.filter((e) => e !== root)]; + + const seen = new Set(); + const out: string[] = []; + for (const entry of ordered) { + if (!entry) continue; + const abs = isAbsolute(entry) ? entry : resolve(cwdReal, entry); + const real = tryRealpath(abs); + if (!seen.has(real)) { + seen.add(real); + out.push(real); + } + } + return out; +} diff --git a/packages/cli/test/utils/paths.test.ts b/packages/cli/test/utils/paths.test.ts index 4e80ea4a..e8236b72 100644 --- a/packages/cli/test/utils/paths.test.ts +++ b/packages/cli/test/utils/paths.test.ts @@ -1,6 +1,14 @@ -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { getOutputFilePath, getRaftersPaths, getTokenFilePath } from '../../src/utils/paths.js'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + getOutputFilePath, + getRaftersPaths, + getTokenFilePath, + resolveReadSet, + resolveRoot, +} from '../../src/utils/paths.js'; describe('getRaftersPaths', () => { it('should return correct paths for project root', () => { @@ -60,3 +68,70 @@ describe('getOutputFilePath', () => { ); }); }); + +describe('resolveRoot', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'rafters-paths-')); + mkdirSync(join(tmp, 'src/composites'), { recursive: true }); + mkdirSync(join(tmp, 'src/legacy'), { recursive: true }); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns a single string field as-is', () => { + expect(resolveRoot('src/composites', tmp, 'composites')).toBe('src/composites'); + }); + + it('picks the entry tagged { root: true }', () => { + const field = [ + 'src/composites', + { path: 'src/legacy', root: true as const }, + '../shared/composites', + ]; + expect(resolveRoot(field, tmp, 'composites')).toBe('src/legacy'); + }); + + it('picks the first entry that resolves inside cwd when no explicit root', () => { + const field = ['../shared/composites', 'src/composites']; + expect(resolveRoot(field, tmp, 'composites')).toBe('src/composites'); + }); + + it('falls back when zero entries resolve inside cwd', () => { + const field = ['../shared/composites', '/abs/external/composites']; + expect(resolveRoot(field, tmp, 'composites')).toBe('composites'); + }); +}); + +describe('resolveReadSet', () => { + let tmp: string; + let real: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'rafters-paths-')); + mkdirSync(join(tmp, 'src/composites'), { recursive: true }); + mkdirSync(join(tmp, 'shared'), { recursive: true }); + real = realpathSync(tmp); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns single string as one absolute entry', () => { + const out = resolveReadSet('src/composites', tmp); + expect(out).toHaveLength(1); + expect(out[0]).toBe(resolve(real, 'src/composites')); + }); + + it('puts the install root first regardless of array order', () => { + const field = ['../shared', 'src/composites']; + const out = resolveReadSet(field, tmp); + expect(out[0]).toBe(resolve(real, 'src/composites')); + expect(out[1]).toBe(resolve(real, '../shared')); + }); + + it('deduplicates entries that realpath to the same location', () => { + const out = resolveReadSet(['src/composites', 'src/composites'], tmp); + expect(out).toHaveLength(1); + }); +}); From ef2aa96a5cc27e4e5bccfbb770fc55f6c4f68fe8 Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Fri, 24 Apr 2026 22:02:58 -0700 Subject: [PATCH 2/3] refactor(paths): collapse redundant existsSync branches in resolveRoot The two-branch check (one for existing path, one for non-existing) was doing the same thing -- tryRealpath already returns the abs path on ENOENT, so a single isInsideCwd check covers both. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/utils/paths.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/utils/paths.ts b/packages/cli/src/utils/paths.ts index 0cd05fb6..64e97a44 100644 --- a/packages/cli/src/utils/paths.ts +++ b/packages/cli/src/utils/paths.ts @@ -2,7 +2,7 @@ * Path utilities for .rafters/ directory structure */ -import { existsSync, realpathSync } from 'node:fs'; +import { realpathSync } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { z } from 'zod'; @@ -113,12 +113,7 @@ export function resolveRoot(field: PathField, cwd: string, fallback: string): st for (const entry of field) { const p = entryPath(entry); const abs = isAbsolute(p) ? p : resolve(cwdReal, p); - if (existsSync(abs) && isInsideCwd(tryRealpath(abs), cwdReal)) { - return p; - } - if (!existsSync(abs) && isInsideCwd(resolve(cwdReal, p), cwdReal)) { - return p; - } + if (isInsideCwd(tryRealpath(abs), cwdReal)) return p; } return fallback; From 36466cbef6a85749a47b9c21c30a64be653719ac Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Fri, 24 Apr 2026 22:12:21 -0700 Subject: [PATCH 3/3] test(integration): add rules: [] to installed shape assertion The init.installed shape gained a rules array in the multi-folder paths change but the integration assertion still listed only three buckets. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/test/integration/init.integration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/test/integration/init.integration.test.ts b/packages/cli/test/integration/init.integration.test.ts index 42c13021..163eb8db 100644 --- a/packages/cli/test/integration/init.integration.test.ts +++ b/packages/cli/test/integration/init.integration.test.ts @@ -47,6 +47,7 @@ describe('rafters init - fresh initialization', () => { components: [], primitives: [], composites: [], + rules: [], }); }, 30000);