From 79cfe855c9d0792cdbe9f6d3d7d01aacc7147920 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 15 Feb 2026 14:15:04 +0100 Subject: [PATCH] feat: enhanced glob patterns, symlink handling, and Tailwind CSS compilation - Added `recursive` option to GlobOptions to control whether simple glob patterns match recursively by default (true). - Changed glob pattern behavior to always apply recursive matching for simple patterns instead of requiring explicit `**/` prefix. - Improved symlink handling in glob file type filters to resolve symlink targets and match based on their actual type (file/dir). - Enhanced skill discovery in coding-agent to use dual-pass glob approach with deduplication and improved extension module detection. - Implemented dynamic Tailwind CSS compilation in stats package by extracting actual class names from source files instead of building with empty candidates. - Refactored Tailwind stylesheet imports to use centralized `tailwindcss/index.css` import with custom stylesheet loader callback. --- crates/pi-natives/src/glob.rs | 66 ++++++++++--- .../coding-agent/src/discovery/helpers.ts | 96 ++++++++++++------- packages/natives/CHANGELOG.md | 8 ++ packages/natives/src/glob/index.ts | 7 +- packages/natives/src/glob/types.ts | 6 +- packages/stats/build.ts | 56 ++++++++++- packages/stats/src/client/styles.css | 5 +- packages/stats/src/server.ts | 49 +++++++++- 8 files changed, 228 insertions(+), 65 deletions(-) diff --git a/crates/pi-natives/src/glob.rs b/crates/pi-natives/src/glob.rs index e4a226dca..2521ff6db 100644 --- a/crates/pi-natives/src/glob.rs +++ b/crates/pi-natives/src/glob.rs @@ -34,9 +34,12 @@ pub struct GlobOptions<'env> { pub pattern: String, /// Directory to search. pub path: String, - /// Filter by file type: "file", "dir", or "symlink". + /// Filter by file type: "file", "dir", or "symlink". Symlinks are + /// matched for file/dir filters based on their target type. #[napi(js_name = "fileType")] pub file_type: Option, + /// Match simple patterns recursively by default (`*.ts` -> recursive). + pub recursive: Option, /// Include hidden files (default: false). pub hidden: Option, /// Maximum number of results to return. @@ -69,22 +72,22 @@ pub struct GlobResult { pub total_matches: u32, } -fn build_glob_pattern(glob: &str) -> String { +fn build_glob_pattern(glob: &str, recursive: bool) -> String { let normalized = if cfg!(windows) && glob.contains('\\') { std::borrow::Cow::Owned(glob.replace('\\', "/")) } else { std::borrow::Cow::Borrowed(glob) }; - if normalized.contains('/') || normalized.starts_with("**") { + if !recursive || normalized.contains('/') || normalized.starts_with("**") { normalized.into_owned() } else { format!("**/{normalized}") } } -fn compile_glob(glob: &str) -> Result { +fn compile_glob(glob: &str, recursive: bool) -> Result { let mut builder = GlobSetBuilder::new(); - let pattern = build_glob_pattern(glob); + let pattern = build_glob_pattern(glob, recursive); let glob = Glob::new(&pattern) .map_err(|err| Error::from_reason(format!("Invalid glob pattern: {err}")))?; builder.add(glob); @@ -97,6 +100,7 @@ fn compile_glob(glob: &str) -> Result { struct GlobConfig { root: std::path::PathBuf, pattern: String, + recursive: bool, include_hidden: bool, file_type_filter: Option, max_results: usize, @@ -106,6 +110,41 @@ struct GlobConfig { use_cache: bool, } +fn resolve_symlink_target_type(root: &Path, relative_path: &str) -> Option { + let target_path = root.join(relative_path); + let metadata = std::fs::metadata(target_path).ok()?; + if metadata.is_dir() { + Some(FileType::Dir) + } else if metadata.is_file() { + Some(FileType::File) + } else { + None + } +} + +fn apply_file_type_filter(entry: &GlobMatch, config: &GlobConfig) -> Option { + let Some(filter) = config.file_type_filter else { + return Some(entry.file_type); + }; + if entry.file_type == filter { + return Some(entry.file_type); + } + if entry.file_type != FileType::Symlink { + return None; + } + match filter { + FileType::File | FileType::Dir => { + let resolved = resolve_symlink_target_type(&config.root, &entry.path)?; + if resolved == filter { + Some(resolved) + } else { + None + } + }, + FileType::Symlink => None, + } +} + /// Filter and collect matching entries from a pre-scanned list. fn filter_entries( entries: &[GlobMatch], @@ -128,17 +167,16 @@ fn filter_entries( if !glob_set.is_match(&entry.path) { continue; } - if config - .file_type_filter - .is_some_and(|filter| filter != entry.file_type) - { + let Some(effective_file_type) = apply_file_type_filter(entry, config) else { continue; - } + }; + let mut matched_entry = entry.clone(); + matched_entry.file_type = effective_file_type; if let Some(callback) = on_match { - callback.call(Ok(entry.clone()), ThreadsafeFunctionCallMode::NonBlocking); + callback.call(Ok(matched_entry.clone()), ThreadsafeFunctionCallMode::NonBlocking); } - matches.push(entry.clone()); + matches.push(matched_entry); // Only early-break when not sorting; mtime sort requires full candidate set. if !config.sort_by_mtime && matches.len() >= config.max_results { break; @@ -154,7 +192,7 @@ fn run_glob( on_match: Option<&ThreadsafeFunction>, ct: task::CancelToken, ) -> Result { - let glob_set = compile_glob(&config.pattern)?; + let glob_set = compile_glob(&config.pattern, config.recursive)?; if config.max_results == 0 { return Ok(GlobResult { matches: Vec::new(), total_matches: 0 }); } @@ -225,6 +263,7 @@ pub fn glob( pattern, path, file_type, + recursive, hidden, max_results, gitignore, @@ -247,6 +286,7 @@ pub fn glob( root: fs_cache::resolve_search_path(&path)?, include_hidden: hidden.unwrap_or(false), file_type_filter: file_type, + recursive: recursive.unwrap_or(true), max_results: max_results.map_or(usize::MAX, |value| value as usize), use_gitignore: gitignore.unwrap_or(true), mentions_node_modules: include_node_modules diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index 59c09135d..43cf2070a 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -225,6 +225,20 @@ export function parseAgentFields(frontmatter: Record): ParsedAg return { name, description, tools, spawns, model, output, thinkingLevel }; } +async function globIf( + dir: string, + pattern: string, + fileType: FileType, + recursive: boolean = true, +): Promise> { + try { + const result = await glob({ pattern, path: dir, gitignore: true, hidden: false, fileType, recursive }); + return result.matches; + } catch { + return []; + } +} + export async function loadSkillsFromDir( _ctx: LoadContext, options: { @@ -239,18 +253,19 @@ export async function loadSkillsFromDir( const { dir, level, providerId, requireDescription = false } = options; // Use native glob to find all SKILL.md files one level deep // Pattern */SKILL.md matches //SKILL.md - let matches: Array<{ path: string }>; - try { - const result = await glob({ - pattern: "*/SKILL.md", - path: dir, - gitignore: true, - hidden: false, - fileType: FileType.File, - }); - matches = result.matches; - } catch { - // Directory doesn't exist or isn't readable + const discoveredMatches = new Set(); + for (const match of await globIf(dir, "*/SKILL.md", FileType.File)) { + discoveredMatches.add(match.path); + } + for (const match of await globIf(dir, "*", FileType.Dir, false)) { + const skillRelPath = `${match.path}/SKILL.md`; + const content = await readFile(path.join(dir, skillRelPath)); + if (content !== null) { + discoveredMatches.add(skillRelPath); + } + } + const matches = [...discoveredMatches].map(path => ({ path })); + if (matches.length === 0) { return { items, warnings }; } @@ -456,48 +471,55 @@ async function readExtensionModuleManifest( * Uses native glob for fast filesystem scanning with gitignore support. */ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: string): Promise { - const discovered: string[] = []; + const discovered = new Set(); // Find all candidate files in parallel using glob const [directFiles, indexFiles, packageJsonFiles] = await Promise.all([ // 1. Direct *.ts or *.js files - glob({ pattern: "*.{ts,js}", path: dir, gitignore: true, hidden: false, fileType: FileType.File }), + globIf(dir, "*.{ts,js}", FileType.File, false), // 2. Subdirectory index files - glob({ pattern: "*/index.{ts,js}", path: dir, gitignore: true, hidden: false, fileType: FileType.File }), + globIf(dir, "*/index.{ts,js}", FileType.File), // 3. Subdirectory package.json files - glob({ pattern: "*/package.json", path: dir, gitignore: true, hidden: false, fileType: FileType.File }), + globIf(dir, "*/package.json", FileType.File), ]); // Process direct files - for (const match of directFiles.matches) { - discovered.push(path.join(dir, match.path)); + for (const match of directFiles) { + if (match.path.includes("/")) continue; + discovered.add(path.join(dir, match.path)); } - - // Track which subdirectories have package.json - const subdirsWithPackageJson = new Set(); - for (const match of packageJsonFiles.matches) { + // Track which subdirectories have package.json manifests with declared extensions + const subdirsWithDeclaredExtensions = new Set(); + for (const match of packageJsonFiles) { const subdir = path.dirname(match.path); // e.g., "my-extension" - subdirsWithPackageJson.add(subdir); const packageJsonPath = path.join(dir, match.path); const manifest = await readExtensionModuleManifest(_ctx, packageJsonPath); - if (manifest?.extensions && Array.isArray(manifest.extensions)) { - const subdirPath = path.join(dir, subdir); - for (const extPath of manifest.extensions) { - const resolvedExtPath = path.resolve(subdirPath, extPath); - const content = await readFile(resolvedExtPath); - if (content !== null) { - discovered.push(resolvedExtPath); - } + const declaredExtensions = + manifest?.extensions?.filter((extPath): extPath is string => typeof extPath === "string") ?? []; + if (declaredExtensions.length === 0) continue; + subdirsWithDeclaredExtensions.add(subdir); + const subdirPath = path.join(dir, subdir); + for (const extPath of declaredExtensions) { + const resolvedExtPath = path.resolve(subdirPath, extPath); + const content = await readFile(resolvedExtPath); + if (content !== null) { + discovered.add(resolvedExtPath); } } } - - // Process index files (skip if subdirectory has package.json) - for (const match of indexFiles.matches) { + const preferredIndexBySubdir = new Map(); + for (const match of indexFiles) { + if (match.path.split("/").length !== 2) continue; const subdir = path.dirname(match.path); - if (subdirsWithPackageJson.has(subdir)) continue; // package.json takes precedence - discovered.push(path.join(dir, match.path)); + if (subdirsWithDeclaredExtensions.has(subdir)) continue; + const existing = preferredIndexBySubdir.get(subdir); + if (!existing || (existing.endsWith("index.js") && match.path.endsWith("index.ts"))) { + preferredIndexBySubdir.set(subdir, match.path); + } } - return discovered; + for (const preferredPath of preferredIndexBySubdir.values()) { + discovered.add(path.join(dir, preferredPath)); + } + return [...discovered]; } /** diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 0296f7d1f..ca25eeff7 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- Added `recursive` option to `GlobOptions` to control whether simple patterns match recursively (defaults to true) + +### Changed + +- Changed default glob pattern behavior to always use recursive matching for simple patterns instead of requiring explicit `**/` prefix +- Updated `fileType` filter documentation to clarify that symlinks match file/dir filters based on their target type ## [12.4.0] - 2026-02-14 ### Added diff --git a/packages/natives/src/glob/index.ts b/packages/natives/src/glob/index.ts index 3c69e960b..6c59531de 100644 --- a/packages/natives/src/glob/index.ts +++ b/packages/natives/src/glob/index.ts @@ -16,10 +16,6 @@ export { FileType } from "./types"; export async function glob(options: GlobOptions, onMatch?: (match: GlobMatch) => void): Promise { const searchPath = path.resolve(options.path); const pattern = options.pattern || "*"; - - // Convert simple patterns to recursive globs if needed - const globPattern = pattern.includes("/") || pattern.startsWith("**") ? pattern : `**/${pattern}`; - // napi-rs ThreadsafeFunction passes (error, value) - skip callback on error const cb = onMatch ? (err: Error | null, m: GlobMatch) => !err && onMatch(m) : undefined; @@ -27,9 +23,10 @@ export async function glob(options: GlobOptions, onMatch?: (match: GlobMatch) => { ...options, path: searchPath, - pattern: globPattern, + pattern, hidden: options.hidden ?? false, gitignore: options.gitignore ?? true, + recursive: options.recursive ?? true, }, cb, ); diff --git a/packages/natives/src/glob/types.ts b/packages/natives/src/glob/types.ts index ee1c15b89..f63137820 100644 --- a/packages/natives/src/glob/types.ts +++ b/packages/natives/src/glob/types.ts @@ -19,8 +19,10 @@ export interface GlobOptions extends Cancellable { pattern: string; /** Directory to search. */ path: string; - /** Filter by file type: "file", "dir", or "symlink". */ + /** Filter by file type: "file", "dir", or "symlink". Symlinks match file/dir filters when their target type matches. */ fileType?: FileType; + /** Match simple patterns recursively by default (example: *.ts -> recursive match). Set false to keep patterns relative to the search root only. */ + recursive?: boolean; /** Include hidden files (default: false). */ hidden?: boolean; /** Maximum number of results to return. */ @@ -39,7 +41,7 @@ export interface GlobOptions extends Cancellable { export interface GlobMatch { /** Relative path from the search root. */ path: string; - /** Resolved filesystem type for the match. */ + /** Resolved filesystem type for the match (for fileType=file/dir filters, symlink targets are reported as file/dir). */ fileType: FileType; /** Modification time in milliseconds since epoch, if available. */ mtime?: number; diff --git a/packages/stats/build.ts b/packages/stats/build.ts index 8b5898b2b..3c64713de 100644 --- a/packages/stats/build.ts +++ b/packages/stats/build.ts @@ -2,19 +2,71 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { compile } from "tailwindcss"; +/** + * Extract Tailwind class names from source files by scanning for className attributes. + */ +async function extractTailwindClasses(dir: string): Promise> { + const classes = new Set(); + const classPattern = /className\s*=\s*["'`]([^"'`]+)["'`]/g; + + async function scanDir(currentDir: string): Promise { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + await scanDir(fullPath); + } else if (entry.isFile() && /\.(tsx|ts|jsx|js)$/.test(entry.name)) { + const content = await Bun.file(fullPath).text(); + const matches = content.matchAll(classPattern); + for (const match of matches) { + for (const cls of match[1].split(/\s+/)) { + if (cls) classes.add(cls); + } + } + } + } + } + + await scanDir(dir); + return classes; +} + // Clean dist await fs.rm("./dist/client", { recursive: true, force: true }); // Build Tailwind CSS console.log("Building Tailwind CSS..."); const sourceCss = await Bun.file("./src/client/styles.css").text(); +const candidates = await extractTailwindClasses("./src/client"); +const baseDir = path.resolve("./src/client"); + +// Provide loadStylesheet for @import "tailwindcss" to work const compiler = await compile(sourceCss, { - base: path.resolve("./src/client"), + base: baseDir, + loadStylesheet: async (id: string, base: string) => { + // Handle tailwindcss import + if (id === "tailwindcss/index.css" || id === "tailwindcss") { + const tailwindPath = require.resolve("tailwindcss/index.css", { paths: [base] }); + return { + path: tailwindPath, + base: path.dirname(tailwindPath), + content: await Bun.file(tailwindPath).text(), + }; + } + // Handle other imports relative to base + const resolved = path.resolve(base, id); + return { + path: resolved, + base: path.dirname(resolved), + content: await Bun.file(resolved).text(), + }; + }, }); -const tailwindOutput = compiler.build([]); +const tailwindOutput = compiler.build([...candidates]); await Bun.write("./dist/client/styles.css", tailwindOutput); // Build React app +console.log("Building React app..."); const result = await Bun.build({ entrypoints: ["./src/client/index.tsx"], outdir: "./dist/client", diff --git a/packages/stats/src/client/styles.css b/packages/stats/src/client/styles.css index cdb24cce5..b69120839 100644 --- a/packages/stats/src/client/styles.css +++ b/packages/stats/src/client/styles.css @@ -1,7 +1,4 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - +@import "tailwindcss/index.css"; :root { --bg-page: #0a0a0f; --bg-surface: #111118; diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index db76cfeea..17927c3ea 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -12,6 +12,33 @@ import { } from "./aggregator"; import { EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64 } from "./embedded-client.generated"; +/** + * Extract Tailwind class names from source files by scanning for className attributes. + */ +async function extractTailwindClasses(dir: string): Promise> { + const classes = new Set(); + const classPattern = /className\s*=\s*["'`]([^"'`]+)["'`]/g; + async function scanDir(currentDir: string): Promise { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + await scanDir(fullPath); + } else if (entry.isFile() && /\.(tsx|ts|jsx|js)$/.test(entry.name)) { + const content = await Bun.file(fullPath).text(); + const matches = content.matchAll(classPattern); + for (const match of matches) { + for (const cls of match[1].split(/\s+/)) { + if (cls) classes.add(cls); + } + } + } + } + } + await scanDir(dir); + return classes; +} + const CLIENT_DIR = path.join(import.meta.dir, "client"); const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client"); const IS_BUN_COMPILED = @@ -74,10 +101,28 @@ async function getCompiledClientDir(): Promise { async function buildTailwindCss(inputPath: string, outputPath: string): Promise { const sourceCss = await Bun.file(inputPath).text(); + const clientDir = path.dirname(inputPath); + const candidates = await extractTailwindClasses(clientDir); const compiler = await compile(sourceCss, { - base: path.dirname(inputPath), + base: clientDir, + loadStylesheet: async (id: string, base: string) => { + if (id === "tailwindcss/index.css" || id === "tailwindcss") { + const tailwindPath = require.resolve("tailwindcss/index.css", { paths: [base] }); + return { + path: tailwindPath, + base: path.dirname(tailwindPath), + content: await Bun.file(tailwindPath).text(), + }; + } + const resolved = path.resolve(base, id); + return { + path: resolved, + base: path.dirname(resolved), + content: await Bun.file(resolved).text(), + }; + }, }); - const result = compiler.build([]); + const result = compiler.build([...candidates]); await Bun.write(outputPath, result); }