diff --git a/package-lock.json b/package-lock.json index 359b7a1..d5eeb25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@microsoft/agentrc", "version": "2.0.0", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@github/copilot-sdk": "^0.1.29", "@inquirer/prompts": "^8.2.1", @@ -43,6 +46,10 @@ "vitest": "^4.0.18" } }, + "node_modules/@agentrc/core": { + "resolved": "packages/core", + "link": true + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", @@ -7586,6 +7593,10 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "packages/core": { + "name": "@agentrc/core", + "version": "2.0.0" } } } diff --git a/package.json b/package.json index 63040bf..f81373b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Set up repositories for AI-assisted development", "main": "dist/index.js", "type": "module", + "workspaces": [ + "packages/*" + ], "bin": { "agentrc": "dist/index.js" }, diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..7f17859 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,12 @@ +{ + "name": "@agentrc/core", + "version": "2.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./config": "./src/config.ts", + "./services/*": "./src/services/*.ts", + "./utils/*": "./src/utils/*.ts" + } +} diff --git a/src/config.ts b/packages/core/src/config.ts similarity index 100% rename from src/config.ts rename to packages/core/src/config.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..7f61263 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,133 @@ +// @agentrc/core barrel — re-exports all public API surface + +// Config +export { DEFAULT_MODEL, DEFAULT_JUDGE_MODEL } from "./config"; + +// Services +export { + analyzeRepo, + detectWorkspaces, + loadAgentrcConfig, + sanitizeAreaName +} from "./services/analyzer"; +export type { + RepoApp, + Area, + RepoAnalysis, + InstructionStrategy, + AgentrcConfig, + AgentrcConfigArea, + AgentrcConfigWorkspace +} from "./services/analyzer"; + +export { + createPullRequest as createAzurePullRequest, + getRepo as getAzureDevOpsRepo +} from "./services/azureDevops"; + +export { + processGitHubRepo, + processAzureRepo, + runBatchHeadlessGitHub, + runBatchHeadlessAzure, + processBatchReadinessRepo, + sanitizeError +} from "./services/batch"; + +export { + assertCopilotCliReady, + listCopilotModels, + buildExecArgs, + logCopilotDebug +} from "./services/copilot"; +export type { CopilotCliConfig } from "./services/copilot"; + +export { + loadCopilotSdk, + createCopilotClient, + attachDefaultPermissionHandler +} from "./services/copilotSdk"; + +export { generateEvalScaffold } from "./services/evalScaffold"; + +export { runEval } from "./services/evaluator"; + +export { generateConfigs } from "./services/generator"; +export type { FileAction, GenerateResult, GenerateOptions } from "./services/generator"; + +export { + isGitRepo, + cloneRepo, + setRemoteUrl, + checkoutBranch, + commitAll, + buildAuthedUrl, + pushBranch +} from "./services/git"; + +export { + getGitHubToken, + createGitHubClient, + listAccessibleRepos, + getRepo as getGitHubRepo, + createPullRequest, + listUserOrgs, + listOrgRepos, + checkRepoHasInstructions, + checkReposForInstructions +} from "./services/github"; +export type { GitHubRepo, GitHubOrg } from "./services/github"; + +export { + generateCopilotInstructions, + generateAreaInstructions, + generateNestedInstructions, + generateNestedAreaInstructions, + writeAreaInstruction, + writeNestedInstructions, + detectExistingInstructions, + buildExistingInstructionsSection +} from "./services/instructions"; + +export { parsePolicySources, loadPolicy, resolveChain } from "./services/policy"; + +export { + runReadinessReport, + groupPillars, + getLevelName, + getLevelDescription, + buildCriteria +} from "./services/readiness"; +export type { + ReadinessReport, + ReadinessCriterionResult, + ReadinessExtraResult +} from "./services/readiness"; + +export { generateVisualReport } from "./services/visualReport"; + +// Utils +export { + ensureDir, + safeWriteFile, + validateCachePath, + fileExists, + safeReadDir, + readJson, + buildTimestampedName +} from "./utils/fs"; +export type { WriteResult } from "./utils/fs"; + +export { prettyPrintSummary } from "./utils/logger"; + +export { + createProgressReporter, + shouldLog, + outputResult, + deriveFileStatus, + outputError +} from "./utils/output"; +export type { CommandResult, ProgressReporter } from "./utils/output"; + +export { isAgentrcFile, buildInstructionsPrBody, buildFullPrBody } from "./utils/pr"; +export { GITHUB_REPO_RE, AZURE_REPO_RE } from "./utils/repo"; diff --git a/src/services/analyzer.ts b/packages/core/src/services/analyzer.ts similarity index 100% rename from src/services/analyzer.ts rename to packages/core/src/services/analyzer.ts diff --git a/packages/core/src/services/analyzer/apps.ts b/packages/core/src/services/analyzer/apps.ts new file mode 100644 index 0000000..97182eb --- /dev/null +++ b/packages/core/src/services/analyzer/apps.ts @@ -0,0 +1,544 @@ +import fs from "fs/promises"; +import path from "path"; + +import fg from "fast-glob"; + +import { fileExists, safeReadDir, readJson } from "../../utils/fs"; + +import type { RepoApp, RepoAnalysis } from "./types"; +import { PACKAGE_MANAGERS } from "./types"; + +export async function detectPackageManager( + _repoPath: string, + files: string[] +): Promise { + for (const manager of PACKAGE_MANAGERS) { + if (files.includes(manager.file)) return manager.name; + } + + if (files.includes("package.json")) return "npm"; + if (files.includes("pyproject.toml")) return "pip"; + if (files.includes("Cargo.toml")) return "cargo"; + if (files.includes("go.mod")) return "go"; + if (files.includes("pom.xml")) return "maven"; + if (files.includes("build.gradle") || files.includes("build.gradle.kts")) return "gradle"; + if (files.includes("Gemfile")) return "bundler"; + if (files.includes("composer.json")) return "composer"; + if (files.some((f) => f.endsWith(".sln") || f.endsWith(".slnx"))) return "nuget"; + if ( + files.includes("MODULE.bazel") || + files.includes("WORKSPACE") || + files.includes("WORKSPACE.bazel") + ) + return "bazel"; + if (files.includes("pants.toml")) return "pants"; + if (files.includes("nx.json")) return "nx"; + return undefined; +} + +export function detectFrameworks(deps: string[], files: string[]): string[] { + const frameworks: string[] = []; + const hasFile = (file: string): boolean => files.includes(file); + + if (deps.includes("next") || hasFile("next.config.js") || hasFile("next.config.mjs")) + frameworks.push("Next.js"); + if (deps.includes("react") || deps.includes("react-dom")) frameworks.push("React"); + if (deps.includes("vue") || hasFile("vue.config.js")) frameworks.push("Vue"); + if (deps.includes("@angular/core") || hasFile("angular.json")) frameworks.push("Angular"); + if (deps.includes("svelte") || hasFile("svelte.config.js")) frameworks.push("Svelte"); + if (deps.includes("express")) frameworks.push("Express"); + if (deps.includes("@nestjs/core")) frameworks.push("NestJS"); + if (deps.includes("fastify")) frameworks.push("Fastify"); + + return frameworks; +} + +async function safeReadFile(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + return undefined; + } +} + +export async function isScannableDirectory( + repoPath: string, + candidatePath: string +): Promise { + try { + const stat = await fs.lstat(candidatePath); + if (stat.isSymbolicLink() || !stat.isDirectory()) return false; + + const resolvedRoot = await fs.realpath(repoPath).catch(() => path.resolve(repoPath)); + const resolvedCandidate = await fs + .realpath(candidatePath) + .catch(() => path.resolve(candidatePath)); + + return ( + resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(resolvedRoot + path.sep) + ); + } catch { + return false; + } +} + +type WorkspaceConfig = { + type: "npm" | "pnpm" | "yarn"; + patterns: string[]; +}; + +export async function detectWorkspace( + repoPath: string, + files: string[], + packageJson?: Record +): Promise { + if (files.includes("pnpm-workspace.yaml")) { + const patterns = await readPnpmWorkspace(path.join(repoPath, "pnpm-workspace.yaml")); + if (patterns.length) return { type: "pnpm", patterns }; + } + + const workspaces = packageJson?.workspaces; + if (Array.isArray(workspaces)) { + return { type: files.includes("yarn.lock") ? "yarn" : "npm", patterns: workspaces.map(String) }; + } + + if (workspaces && typeof workspaces === "object") { + const packages = (workspaces as { packages?: unknown }).packages; + if (Array.isArray(packages)) { + return { type: files.includes("yarn.lock") ? "yarn" : "npm", patterns: packages.map(String) }; + } + } + + return undefined; +} + +async function readPnpmWorkspace(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + const lines = raw.split(/\r?\n/u); + const patterns: string[] = []; + let inPackages = false; + for (const line of lines) { + // Skip comment-only lines + if (/^\s*#/u.test(line)) continue; + if (!inPackages && /^\s*packages\s*:/u.test(line)) { + // Handle inline array: packages: ["apps/*", "libs/*"] + const inline = line.match(/packages\s*:\s*\[([^\]]+)\]/u); + if (inline) { + const items = inline[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/gu, "")); + return items.filter(Boolean); + } + inPackages = true; + continue; + } + if (inPackages) { + const match = line.match(/^\s*-\s*(.+)$/u); + if (match?.[1]) { + // Strip trailing comments and quotes + const value = match[1] + .split("#")[0] + .trim() + .replace(/^['"]|['"]$/gu, ""); + if (value) patterns.push(value); + continue; + } + // Non-indented, non-empty line means a new top-level key + if (/^\S/u.test(line) && line.trim()) break; + } + } + return patterns; + } catch { + return []; + } +} + +export async function resolveWorkspaceApps( + repoPath: string, + patterns: string[], + rootPackageJson?: Record +): Promise { + const workspacePatterns = patterns + .map((pattern) => pattern.replace(/\\/gu, "/")) + .map((pattern) => + pattern.endsWith("package.json") ? pattern : path.posix.join(pattern, "package.json") + ); + + const packageJsonPaths = workspacePatterns.length + ? ( + await fg(workspacePatterns, { cwd: repoPath, absolute: true, onlyFiles: true, dot: false }) + ).map((p) => path.normalize(p)) + : []; + + if (!packageJsonPaths.length && rootPackageJson) { + const rootPath = path.join(repoPath, "package.json"); + return [await buildRepoApp(repoPath, rootPath, rootPackageJson)]; + } + + const apps = await Promise.all( + packageJsonPaths.map(async (pkgPath) => { + const pkg = await readJson(pkgPath); + return buildRepoApp(path.dirname(pkgPath), pkgPath, pkg); + }) + ); + + return apps.filter(Boolean) as RepoApp[]; +} + +async function buildRepoApp( + appPath: string, + packageJsonPath: string, + packageJson?: Record +): Promise { + const scripts = (packageJson?.scripts ?? {}) as Record; + const name = typeof packageJson?.name === "string" ? packageJson.name : path.basename(appPath); + const hasTsConfig = await fileExists(path.join(appPath, "tsconfig.json")); + + return { + name, + path: appPath, + ecosystem: "node", + manifestPath: packageJsonPath, + packageJsonPath, + scripts, + hasTsConfig + }; +} + +function buildNonJsApp( + name: string, + appPath: string, + ecosystem: RepoApp["ecosystem"], + manifestPath: string +): RepoApp { + return { + name, + path: appPath, + ecosystem, + manifestPath, + packageJsonPath: "", + scripts: {}, + hasTsConfig: false + }; +} + +// ─── Non-JS monorepo detection ─── + +type NonJsMonorepoResult = { + type?: RepoAnalysis["workspaceType"]; + patterns?: string[]; + apps: RepoApp[]; +}; + +export async function detectNonJsMonorepo( + repoPath: string, + files: string[] +): Promise { + const cargoApps = await detectCargoWorkspace(repoPath); + if (cargoApps.length > 1) return { type: "cargo", apps: cargoApps }; + + const goApps = await detectGoWorkspace(repoPath); + if (goApps.length > 1) return { type: "go", apps: goApps }; + + const dotnetApps = await detectDotnetSolution(repoPath, files); + if (dotnetApps.length > 1) return { type: "dotnet", apps: dotnetApps }; + + const gradleApps = await detectGradleMultiProject(repoPath, files); + if (gradleApps.length > 1) return { type: "gradle", apps: gradleApps }; + + const mavenApps = await detectMavenMultiModule(repoPath); + if (mavenApps.length > 1) return { type: "maven", apps: mavenApps }; + + const bazelApps = await detectBazelWorkspace(repoPath, files); + if (bazelApps.length > 1) return { type: "bazel", apps: bazelApps }; + + const nxApps = await detectNxWorkspace(repoPath, files); + if (nxApps.length > 1) return { type: "nx", apps: nxApps }; + + const pantsApps = await detectPantsWorkspace(repoPath, files); + if (pantsApps.length > 1) return { type: "pants", apps: pantsApps }; + + return { apps: [] }; +} + +async function detectCargoWorkspace(repoPath: string): Promise { + const content = await safeReadFile(path.join(repoPath, "Cargo.toml")); + if (!content) return []; + + // Extract [workspace] section up to the next section header + const workspaceSection = content.match(/\[workspace\]([\s\S]*?)(?:\n\[|$)/u); + if (!workspaceSection) return []; + + const membersMatch = workspaceSection[1].match(/members\s*=\s*\[([\s\S]*?)\]/u); + if (!membersMatch) return []; + + const patterns = [...membersMatch[1].matchAll(/"([^"]+)"/gu)].map((m) => m[1]); + if (!patterns.length) return []; + + const tomlPaths = ( + await fg( + patterns.map((p) => path.posix.join(p, "Cargo.toml")), + { cwd: repoPath, absolute: true, onlyFiles: true } + ) + ).map((p) => path.normalize(p)); + + return Promise.all( + tomlPaths.map(async (tomlPath) => { + const dir = path.dirname(tomlPath); + const toml = await safeReadFile(tomlPath); + const nameMatch = toml?.match(/^\s*name\s*=\s*"([^"]+)"/mu); + return buildNonJsApp(nameMatch?.[1] ?? path.basename(dir), dir, "rust", tomlPath); + }) + ); +} + +async function detectGoWorkspace(repoPath: string): Promise { + const content = await safeReadFile(path.join(repoPath, "go.work")); + if (!content) return []; + + const modules: string[] = []; + + // Block form: use ( ./cmd/server \n ./pkg/lib ) + const blockMatch = content.match(/use\s*\(([\s\S]*?)\)/u); + if (blockMatch) { + for (const line of blockMatch[1].split(/\r?\n/u)) { + const trimmed = line.replace(/\/\/.*$/u, "").trim(); + if (trimmed) modules.push(trimmed); + } + } + + // Single-line form: use ./cmd/server + for (const match of content.matchAll(/^use\s+(\S+)\s*$/gmu)) { + modules.push(match[1]); + } + + const apps: RepoApp[] = []; + for (const mod of modules) { + const modPath = path.resolve(repoPath, mod); + const goModPath = path.join(modPath, "go.mod"); + if (!(await fileExists(goModPath))) continue; + + const goMod = await safeReadFile(goModPath); + const nameMatch = goMod?.match(/^module\s+(\S+)/mu); + const shortName = nameMatch?.[1]?.split("/").pop() ?? path.basename(modPath); + apps.push(buildNonJsApp(shortName, modPath, "go", goModPath)); + } + + return apps; +} + +async function detectDotnetSolution(repoPath: string, files: string[]): Promise { + // Prefer .slnx (newer XML format) over .sln (legacy text format) + const slnxFile = files.find((f) => f.endsWith(".slnx")); + if (slnxFile) { + return detectSlnxProjects(repoPath, slnxFile); + } + + const slnFile = files.find((f) => f.endsWith(".sln")); + if (!slnFile) return []; + + const content = await safeReadFile(path.join(repoPath, slnFile)); + if (!content) return []; + + // Match: Project("{guid}") = "Name", "path\to\Project.csproj", "{guid}" + const projectRegex = /Project\("[^"]*"\)\s*=\s*"([^"]+)",\s*"([^"]+\.(?:cs|fs|vb)proj)"/giu; + const apps: RepoApp[] = []; + + for (const match of content.matchAll(projectRegex)) { + const name = match[1]; + const projRelPath = match[2].replace(/\\/gu, "/"); + const projPath = path.resolve(repoPath, projRelPath); + const appDir = path.dirname(projPath); + + if (await fileExists(projPath)) { + apps.push(buildNonJsApp(name, appDir, "dotnet", projPath)); + } + } + + return apps; +} + +async function detectSlnxProjects(repoPath: string, slnxFile: string): Promise { + const content = await safeReadFile(path.join(repoPath, slnxFile)); + if (!content) return []; + + // Match: (with optional extra attributes) + const projectRegex = /]*Path="([^"]+\.(?:cs|fs|vb)proj)"[^>]*\/>/giu; + const apps: RepoApp[] = []; + + for (const match of content.matchAll(projectRegex)) { + const projRelPath = match[1].replace(/\\/gu, "/"); + const projPath = path.resolve(repoPath, projRelPath); + const appDir = path.dirname(projPath); + const name = path.basename(appDir); + + if (await fileExists(projPath)) { + apps.push(buildNonJsApp(name, appDir, "dotnet", projPath)); + } + } + + return apps; +} + +async function detectGradleMultiProject(repoPath: string, files: string[]): Promise { + const settingsFile = files.includes("settings.gradle.kts") + ? "settings.gradle.kts" + : files.includes("settings.gradle") + ? "settings.gradle" + : null; + if (!settingsFile) return []; + + const content = await safeReadFile(path.join(repoPath, settingsFile)); + if (!content) return []; + + // Extract all Gradle project references (':app', ':lib:core') from the file + const projectNames: string[] = []; + for (const match of content.matchAll(/['"](:(?:[\w.-]+:)*[\w.-]+)['"]/gu)) { + projectNames.push(match[1].replace(/^:/u, "").replace(/:/gu, "/")); + } + + const uniqueProjects = [...new Set(projectNames)]; + const apps: RepoApp[] = []; + + for (const project of uniqueProjects) { + const projectDir = path.resolve(repoPath, project); + const ktsPath = path.join(projectDir, "build.gradle.kts"); + const groovyPath = path.join(projectDir, "build.gradle"); + + const buildFile = (await fileExists(ktsPath)) + ? ktsPath + : (await fileExists(groovyPath)) + ? groovyPath + : null; + + if (buildFile) { + apps.push(buildNonJsApp(path.basename(project), projectDir, "java", buildFile)); + } + } + + return apps; +} + +async function detectMavenMultiModule(repoPath: string): Promise { + const content = await safeReadFile(path.join(repoPath, "pom.xml")); + if (!content) return []; + + const apps: RepoApp[] = []; + for (const match of content.matchAll(/([^<]+)<\/module>/gu)) { + const modName = match[1].trim(); + const modDir = path.resolve(repoPath, modName); + const pomPath = path.join(modDir, "pom.xml"); + + if (await fileExists(pomPath)) { + apps.push(buildNonJsApp(path.basename(modName), modDir, "java", pomPath)); + } + } + + return apps; +} + +async function detectBazelWorkspace(repoPath: string, files: string[]): Promise { + const hasBazel = + files.includes("MODULE.bazel") || + files.includes("WORKSPACE") || + files.includes("WORKSPACE.bazel"); + if (!hasBazel) return []; + + // Scan first-level directories for BUILD / BUILD.bazel files + const entries = await safeReadDir(repoPath); + const apps: RepoApp[] = []; + + for (const entry of entries) { + if (entry.startsWith(".")) continue; + const fullPath = path.join(repoPath, entry); + if (!(await isScannableDirectory(repoPath, fullPath))) continue; + const children = await safeReadDir(fullPath); + const buildFile = children.includes("BUILD") + ? "BUILD" + : children.includes("BUILD.bazel") + ? "BUILD.bazel" + : undefined; + if (!buildFile) continue; + + apps.push(buildNonJsApp(entry, fullPath, undefined, path.join(fullPath, buildFile))); + } + + return apps; +} + +async function detectNxWorkspace(repoPath: string, files: string[]): Promise { + if (!files.includes("nx.json")) return []; + + // Find project.json files (depth-limited via glob pattern) + const projectJsonPaths = ( + await fg(["*/project.json", "*/*/project.json", "*/*/*/project.json"], { + cwd: repoPath, + absolute: true, + onlyFiles: true, + dot: false, + followSymbolicLinks: false, + ignore: ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/build/**", "**/out/**"] + }) + ).map((p) => path.normalize(p)); + + const apps: RepoApp[] = []; + for (const projPath of projectJsonPaths) { + const projDir = path.dirname(projPath); + const projJson = await readJson(projPath); + const name = typeof projJson?.name === "string" ? projJson.name : path.basename(projDir); + // Detect ecosystem from sibling files + const children = await safeReadDir(projDir); + const ecosystem: RepoApp["ecosystem"] = children.includes("package.json") + ? "node" + : children.includes("Cargo.toml") + ? "rust" + : children.includes("go.mod") + ? "go" + : children.includes("pyproject.toml") + ? "python" + : undefined; + apps.push({ + name, + path: projDir, + ecosystem, + manifestPath: projPath, + packageJsonPath: children.includes("package.json") ? path.join(projDir, "package.json") : "", + scripts: {}, + hasTsConfig: children.includes("tsconfig.json") + }); + } + + return apps; +} + +async function detectPantsWorkspace(repoPath: string, files: string[]): Promise { + if (!files.includes("pants.toml")) return []; + + // Scan first-level directories for BUILD files + const entries = await safeReadDir(repoPath); + const apps: RepoApp[] = []; + + for (const entry of entries) { + if (entry.startsWith(".")) continue; + const fullPath = path.join(repoPath, entry); + if (!(await isScannableDirectory(repoPath, fullPath))) continue; + const children = await safeReadDir(fullPath); + const buildFile = children.includes("BUILD") + ? "BUILD" + : children.includes("BUILD.pants") + ? "BUILD.pants" + : undefined; + if (!buildFile) continue; + + // Infer ecosystem from sibling files + const ecosystem: RepoApp["ecosystem"] = children.includes("pyproject.toml") + ? "python" + : children.includes("go.mod") + ? "go" + : children.includes("Cargo.toml") + ? "rust" + : undefined; + apps.push(buildNonJsApp(entry, fullPath, ecosystem, path.join(fullPath, buildFile))); + } + + return apps; +} diff --git a/packages/core/src/services/analyzer/areas.ts b/packages/core/src/services/analyzer/areas.ts new file mode 100644 index 0000000..6092041 --- /dev/null +++ b/packages/core/src/services/analyzer/areas.ts @@ -0,0 +1,369 @@ +import path from "path"; + +import { safeReadDir, readJson } from "../../utils/fs"; + +import { isScannableDirectory } from "./apps"; +import { loadAgentrcConfig } from "./config"; +import type { AgentrcConfigArea } from "./config"; +import type { RepoApp, Area, RepoAnalysis } from "./types"; + +export function unique(items: T[]): T[] { + return Array.from(new Set(items)); +} + +const AREA_HEURISTIC_DIRS = [ + "frontend", + "backend", + "api", + "web", + "mobile", + "app", + "server", + "client", + "infra", + "infrastructure", + "shared", + "common", + "lib", + "libs", + "packages", + "services", + "docs", + "scripts", + "tools", + "cli", + "sdk", + "core", + "admin", + "portal", + "dashboard", + "worker", + "functions", + // Browser / engine components + "browser", + "devtools", + "toolkit", + "dom", + "layout", + "media", + "security", + "testing", + "extensions", + "modules", + "editor", + "remote", + "storage" +]; + +// Directories to skip in fallback area detection +const FALLBACK_SKIP_DIRS = new Set([ + "node_modules", + ".git", + ".hg", + ".svn", + "target", + "build", + "dist", + "out", + "output", + ".output", + ".next", + "vendor", + "third_party", + "other-licenses", + "coverage", + "__pycache__", + ".cache", + ".vscode", + ".idea", + ".github", + ".gitlab", + ".circleci", + "supply-chain", + "gradle", + ".cargo" +]); + +const MIN_FALLBACK_CHILDREN = 3; +const MIN_AREAS_FOR_FALLBACK = 3; +const MIN_TOPLEVEL_DIRS_FOR_FALLBACK = 10; + +const MANIFEST_FILES = [ + "package.json", + "pyproject.toml", + "requirements.txt", + "go.mod", + "Cargo.toml", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "Gemfile", + "composer.json", + "setup.py", + "setup.cfg", + "CMakeLists.txt", + "meson.build", + "BUILD", + "BUILD.bazel", + "moz.build" +]; + +const CODE_EXTENSIONS = [ + ".ts", + ".js", + ".py", + ".go", + ".rs", + ".java", + ".cs", + ".rb", + ".php", + ".c", + ".cc", + ".cpp", + ".h", + ".hpp", + ".swift", + ".kt", + ".scala" +]; + +function areasFromApps(repoPath: string, apps: RepoApp[]): Area[] { + return apps.map((app) => { + const rel = path.relative(repoPath, app.path).replace(/\\/gu, "/"); + return { + name: app.name, + applyTo: `${rel}/**`, + path: app.path, + ecosystem: app.ecosystem, + source: "auto" as const, + scripts: Object.keys(app.scripts).length > 0 ? app.scripts : undefined, + hasTsConfig: app.hasTsConfig || undefined + }; + }); +} + +export async function areasFromHeuristics(repoPath: string): Promise { + const entries = await safeReadDir(repoPath); + const areas: Area[] = []; + + for (const entry of entries) { + const lower = entry.toLowerCase(); + if (!AREA_HEURISTIC_DIRS.includes(lower)) continue; + + const fullPath = path.join(repoPath, entry); + if (!(await isScannableDirectory(repoPath, fullPath))) continue; + + // Check if the directory has meaningful content (manifest or code files) + const children = await safeReadDir(fullPath); + const hasManifest = children.some((c) => MANIFEST_FILES.includes(c)); + const hasCode = children.some((c) => CODE_EXTENSIONS.some((ext) => c.endsWith(ext))); + const hasSrcDir = children.includes("src"); + + if (!hasManifest && !hasCode && !hasSrcDir) continue; + + // Read scripts from manifest if present + let scripts: Record | undefined; + let hasTsConfig: boolean | undefined; + if (children.includes("package.json")) { + const pkg = await readJson(path.join(fullPath, "package.json")); + const pkgScripts = (pkg?.scripts ?? {}) as Record; + if (Object.keys(pkgScripts).length > 0) scripts = pkgScripts; + } + if (children.includes("tsconfig.json")) { + hasTsConfig = true; + } + + areas.push({ + name: entry, + applyTo: `${entry}/**`, + path: fullPath, + source: "auto", + scripts, + hasTsConfig + }); + } + + return areas; +} + +/** + * Fallback area detection for large repos (e.g., Firefox, Chromium) where + * neither workspace managers nor the heuristic directory list provide good coverage. + * Scans first-level directories that contain code or manifests and meet a + * small minimum-size threshold to reduce noise. + */ +async function areasFromFallback(repoPath: string, existingAreas: Area[]): Promise { + const existingNames = new Set(existingAreas.map((a) => a.name.toLowerCase())); + const entries = await safeReadDir(repoPath); + const areas: Area[] = []; + + for (const entry of entries) { + if (entry.startsWith(".")) continue; + if (FALLBACK_SKIP_DIRS.has(entry.toLowerCase())) continue; + if (existingNames.has(entry.toLowerCase())) continue; + + const fullPath = path.join(repoPath, entry); + if (!(await isScannableDirectory(repoPath, fullPath))) continue; + + const children = await safeReadDir(fullPath); + if (children.length < MIN_FALLBACK_CHILDREN) continue; + + const hasManifest = children.some((c) => MANIFEST_FILES.includes(c)); + const hasCode = children.some((c) => CODE_EXTENSIONS.some((ext) => c.endsWith(ext))); + const hasSrcDir = children.includes("src"); + + if (!hasManifest && !hasCode && !hasSrcDir) continue; + + areas.push({ + name: entry, + applyTo: `${entry}/**`, + path: fullPath, + source: "auto" + }); + } + + return areas; +} + +const GLOB_CHARS = /[*?[\\]/u; + +function longestNonGlobPrefix(pattern: string): string | undefined { + const segments = pattern.split("/").filter((s) => s !== "."); + const prefixSegments: string[] = []; + for (const segment of segments) { + if (GLOB_CHARS.test(segment)) break; + prefixSegments.push(segment); + } + return prefixSegments.length > 0 ? prefixSegments.join("/") : undefined; +} + +async function resolveConfigArea( + repoPath: string, + resolvedRoot: string, + ca: AgentrcConfigArea +): Promise { + const patterns = Array.isArray(ca.applyTo) ? ca.applyTo : [ca.applyTo]; + const nonGlobPrefix = longestNonGlobPrefix(patterns[0]); + const basePath = nonGlobPrefix ? path.join(repoPath, nonGlobPrefix) : repoPath; + + const resolved = path.resolve(basePath); + if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) return undefined; + + let scripts: Record | undefined; + let hasTsConfig: boolean | undefined; + try { + const children = await safeReadDir(basePath); + if (children.includes("package.json")) { + const pkg = await readJson(path.join(basePath, "package.json")); + const pkgScripts = (pkg?.scripts ?? {}) as Record; + if (Object.keys(pkgScripts).length > 0) scripts = pkgScripts; + } + if (children.includes("tsconfig.json")) hasTsConfig = true; + } catch { + // Directory may not exist yet for config areas + } + + return { + name: ca.name, + description: ca.description, + applyTo: ca.applyTo, + path: basePath, + source: "config" as const, + scripts, + hasTsConfig, + parentArea: ca.parentArea + }; +} + +export async function detectAreas(repoPath: string, analysis: RepoAnalysis): Promise { + let autoAreas: Area[]; + + if (analysis.isMonorepo && analysis.apps && analysis.apps.length > 1) { + const appAreas = areasFromApps(repoPath, analysis.apps); + // Also run heuristics to catch non-app directories (docs, infra, etc.) + const heuristicAreas = await areasFromHeuristics(repoPath); + // Merge: app areas take precedence by name + const byName = new Map(heuristicAreas.map((a) => [a.name.toLowerCase(), a])); + for (const a of appAreas) { + byName.set(a.name.toLowerCase(), a); + } + autoAreas = Array.from(byName.values()); + } else { + autoAreas = await areasFromHeuristics(repoPath); + } + + // Smart fallback: if few areas detected but repo has many top-level dirs, + // scan all first-level directories for code content + const topLevelEntries = await safeReadDir(repoPath); + const topLevelDirCount = ( + await Promise.all( + topLevelEntries + .filter((e) => !e.startsWith(".")) + .map(async (e) => isScannableDirectory(repoPath, path.join(repoPath, e))) + ) + ).filter(Boolean).length; + + if ( + autoAreas.length < MIN_AREAS_FOR_FALLBACK && + topLevelDirCount > MIN_TOPLEVEL_DIRS_FOR_FALLBACK + ) { + const fallbackAreas = await areasFromFallback(repoPath, autoAreas); + const byName = new Map(autoAreas.map((a) => [a.name.toLowerCase(), a])); + for (const a of fallbackAreas) { + if (!byName.has(a.name.toLowerCase())) { + byName.set(a.name.toLowerCase(), a); + } + } + autoAreas = Array.from(byName.values()); + } + + // Merge with config areas (flat + workspace) + const config = await loadAgentrcConfig(repoPath); + if (!config?.areas?.length && !config?.workspaces?.length) return autoAreas; + + const resolvedRoot = path.resolve(repoPath); + const configAreas: Area[] = []; + + // Process flat config areas + for (const ca of config.areas ?? []) { + const area = await resolveConfigArea(repoPath, resolvedRoot, ca); + if (area) configAreas.push(area); + } + + // Process workspace areas — flatten into namespaced Area entries + for (const ws of config.workspaces ?? []) { + const wsAbsPath = path.resolve(repoPath, ws.path); + if (!wsAbsPath.startsWith(resolvedRoot + path.sep) && wsAbsPath !== resolvedRoot) continue; + + for (const ca of ws.areas) { + // Resolve applyTo patterns relative to the workspace path + const rawPatterns = Array.isArray(ca.applyTo) ? ca.applyTo : [ca.applyTo]; + const repoRelativePatterns = rawPatterns.map((p) => `${ws.path}/${p}`); + const repoRelativeApplyTo = + repoRelativePatterns.length === 1 ? repoRelativePatterns[0] : repoRelativePatterns; + + const namespacedArea: AgentrcConfigArea = { + ...ca, + name: `${ws.name}/${ca.name}`, + applyTo: repoRelativeApplyTo, + parentArea: ca.parentArea ? `${ws.name}/${ca.parentArea}` : undefined + }; + const area = await resolveConfigArea(repoPath, resolvedRoot, namespacedArea); + if (area) { + area.workingDirectory = ws.path; + configAreas.push(area); + } + } + } + + // Config areas override auto-detected by name (case-insensitive) + const autoByName = new Map(autoAreas.map((a) => [a.name.toLowerCase(), a])); + for (const ca of configAreas) { + autoByName.set(ca.name.toLowerCase(), ca); + } + + return Array.from(autoByName.values()); +} + +// ─── Workspace detection ─── diff --git a/packages/core/src/services/analyzer/config.ts b/packages/core/src/services/analyzer/config.ts new file mode 100644 index 0000000..c1bff24 --- /dev/null +++ b/packages/core/src/services/analyzer/config.ts @@ -0,0 +1,182 @@ +import path from "path"; + +import { readJson } from "../../utils/fs"; + +export type InstructionStrategy = "flat" | "nested"; + +export type AgentrcConfigArea = { + name: string; + applyTo: string | string[]; + description?: string; + parentArea?: string; +}; + +export type AgentrcConfigWorkspace = { + name: string; + path: string; + areas: AgentrcConfigArea[]; +}; + +export type AgentrcConfig = { + areas?: AgentrcConfigArea[]; + workspaces?: AgentrcConfigWorkspace[]; + policies?: string[]; + strategy?: InstructionStrategy; + detailDir?: string; + claudeMd?: boolean; +}; + +function parseConfigAreas(raw: unknown): AgentrcConfigArea[] { + if (!Array.isArray(raw)) return []; + const areas: AgentrcConfigArea[] = []; + for (const entry of raw) { + if ( + typeof entry !== "object" || + entry === null || + typeof (entry as Record).name !== "string" || + (entry as Record).applyTo === undefined + ) + continue; + const e = entry as Record; + if (!(e.name as string).trim()) continue; + const rawApplyTo = e.applyTo; + let applyTo: string | string[]; + if (typeof rawApplyTo === "string") { + applyTo = rawApplyTo; + } else if (Array.isArray(rawApplyTo) && rawApplyTo.every((v) => typeof v === "string")) { + applyTo = rawApplyTo as string[]; + } else { + continue; + } + if ( + (typeof applyTo === "string" && !applyTo.trim()) || + (Array.isArray(applyTo) && applyTo.length === 0) + ) + continue; + const allPatterns = Array.isArray(applyTo) ? applyTo : [applyTo]; + if (allPatterns.some((p) => p.split("/").includes(".."))) continue; + areas.push({ + name: e.name as string, + applyTo, + description: typeof e.description === "string" ? e.description : undefined, + parentArea: typeof e.parentArea === "string" ? e.parentArea : undefined + }); + } + return areas; +} + +export async function loadAgentrcConfig(repoPath: string): Promise { + // Try repo root first, then .github/ + const candidates = [ + path.join(repoPath, "agentrc.config.json"), + path.join(repoPath, ".github", "agentrc.config.json") + ]; + + for (const candidate of candidates) { + const json = await readJson(candidate); + if (!json) continue; + + // Validate shape + if (json.areas !== undefined && !Array.isArray(json.areas)) { + return undefined; + } + const areas = parseConfigAreas(json.areas); + + // Parse policies array + let policies: string[] | undefined; + if (Array.isArray(json.policies)) { + policies = json.policies.filter((p): p is string => typeof p === "string" && p.trim() !== ""); + } + + // Parse strategy + let strategy: InstructionStrategy | undefined; + if (typeof json.strategy === "string") { + const s = json.strategy as string; + if (s === "flat" || s === "nested") { + strategy = s; + } + } + + // Parse detailDir with safety validation + let detailDir: string | undefined; + if (typeof json.detailDir === "string") { + // Normalize separators so validation works on both Windows and POSIX + const dir = (json.detailDir as string).trim().replace(/\\+/gu, "/"); + const blocklist = new Set([".git", "node_modules", ".github", "dist", "build"]); + // Must be a single path segment — no slashes, no traversal, not in blocklist + if ( + dir && + !path.isAbsolute(dir) && + !dir.includes("/") && + !dir.includes("..") && + !blocklist.has(dir) + ) { + detailDir = dir; + } + } + + // Parse claudeMd + const claudeMd = json.claudeMd === true ? true : undefined; + + // Parse workspaces array + const workspaces: AgentrcConfigWorkspace[] = []; + if (Array.isArray(json.workspaces)) { + for (const ws of json.workspaces) { + if (typeof ws !== "object" || ws === null) continue; + const w = ws as Record; + if (typeof w.name !== "string" || !(w.name as string).trim()) continue; + if (typeof w.path !== "string" || !(w.path as string).trim()) continue; + + const wsPath = (w.path as string).trim().replace(/\\+/gu, "/").replace(/\/+$/u, ""); + if (!wsPath) continue; + // Must be relative, no traversal, no absolute path, no root + if (path.isAbsolute(wsPath) || wsPath === "." || wsPath.split("/").includes("..")) continue; + + const wsAreas = parseConfigAreas(w.areas); + if (wsAreas.length === 0) continue; + + workspaces.push({ + name: (w.name as string).trim(), + path: wsPath, + areas: wsAreas + }); + } + } + + // Validate parentArea references — flat areas against flat names, workspace areas within their workspace + const flatNames = new Set(areas.map((a) => a.name.toLowerCase())); + for (const area of areas) { + if (area.parentArea && !flatNames.has(area.parentArea.toLowerCase())) { + area.parentArea = undefined; + } + } + for (const ws of workspaces) { + const wsAreaNames = new Set(ws.areas.map((a) => a.name.toLowerCase())); + for (const area of ws.areas) { + if (area.parentArea && !wsAreaNames.has(area.parentArea.toLowerCase())) { + area.parentArea = undefined; + } + } + } + + return { + areas, + workspaces: workspaces.length ? workspaces : undefined, + policies: policies?.length ? policies : undefined, + strategy, + detailDir, + claudeMd + }; + } + + return undefined; +} + +export function sanitizeAreaName(name: string): string { + const sanitized = name + .toLowerCase() + .replace(/[^a-z0-9-]/gu, "-") + .replace(/-+/gu, "-") + .replace(/^-|-$/gu, ""); + return sanitized || "unnamed"; +} diff --git a/packages/core/src/services/analyzer/index.ts b/packages/core/src/services/analyzer/index.ts new file mode 100644 index 0000000..4778b2a --- /dev/null +++ b/packages/core/src/services/analyzer/index.ts @@ -0,0 +1,125 @@ +import fs from "fs/promises"; +import path from "path"; + +import { safeReadDir, readJson } from "../../utils/fs"; + +import { + detectFrameworks, + detectNonJsMonorepo, + detectPackageManager, + detectWorkspace, + resolveWorkspaceApps +} from "./apps"; +import { detectAreas, unique } from "./areas"; +import type { RepoAnalysis } from "./types"; + +// ── Re-exports: keep the public API surface identical ── +export type { RepoApp, Area, RepoAnalysis } from "./types"; +export { PACKAGE_MANAGERS } from "./types"; +export { detectWorkspaces } from "./workspaces"; +export { loadAgentrcConfig, sanitizeAreaName } from "./config"; +export type { + InstructionStrategy, + AgentrcConfigArea, + AgentrcConfigWorkspace, + AgentrcConfig +} from "./config"; +export { detectAreas } from "./areas"; + +export async function analyzeRepo(repoPath: string): Promise { + const files = await safeReadDir(repoPath); + let isGit = false; + try { + await fs.access(path.join(repoPath, ".git")); + isGit = true; + } catch { + // not a git repo + } + const analysis: RepoAnalysis = { + path: repoPath, + isGitRepo: isGit, + languages: [], + frameworks: [] + }; + + const hasPackageJson = files.includes("package.json"); + const hasTsConfig = files.includes("tsconfig.json"); + const hasPyProject = files.includes("pyproject.toml"); + const hasRequirements = files.includes("requirements.txt"); + const hasGoMod = files.includes("go.mod"); + const hasCargo = files.includes("Cargo.toml"); + const hasCsproj = files.some( + (f) => f.endsWith(".csproj") || f.endsWith(".sln") || f.endsWith(".slnx") + ); + const hasPomXml = files.includes("pom.xml"); + const hasBuildGradle = files.includes("build.gradle") || files.includes("build.gradle.kts"); + const hasGemfile = files.includes("Gemfile"); + const hasComposerJson = files.includes("composer.json"); + const hasCMakeLists = files.includes("CMakeLists.txt"); + const hasMakefile = files.includes("Makefile") || files.includes("GNUmakefile"); + const hasMesonBuild = files.includes("meson.build"); + const hasConfigure = files.includes("configure") || files.includes("configure.ac"); + const hasMozBuild = files.includes("moz.build"); + + if (hasPackageJson) analysis.languages.push("JavaScript"); + if (hasTsConfig) analysis.languages.push("TypeScript"); + if (hasPyProject || hasRequirements) analysis.languages.push("Python"); + if (hasGoMod) analysis.languages.push("Go"); + if (hasCargo) analysis.languages.push("Rust"); + if (hasCsproj) analysis.languages.push("C#"); + if (hasPomXml || hasBuildGradle) analysis.languages.push("Java"); + if (hasGemfile) analysis.languages.push("Ruby"); + if (hasComposerJson) analysis.languages.push("PHP"); + if (hasCMakeLists || hasMesonBuild || hasConfigure || hasMozBuild) analysis.languages.push("C++"); + if (hasMakefile && !analysis.languages.length) analysis.languages.push("C"); + + analysis.packageManager = await detectPackageManager(repoPath, files); + + let rootPackageJson: Record | undefined; + + if (hasPackageJson) { + rootPackageJson = await readJson(path.join(repoPath, "package.json")); + const deps = Object.keys({ + ...(rootPackageJson?.dependencies ?? {}), + ...(rootPackageJson?.devDependencies ?? {}) + }); + analysis.frameworks.push(...detectFrameworks(deps, files)); + } + + const workspace = await detectWorkspace(repoPath, files, rootPackageJson); + if (workspace) { + analysis.workspaceType = workspace.type; + analysis.workspacePatterns = workspace.patterns; + } + + let apps = await resolveWorkspaceApps(repoPath, workspace?.patterns ?? [], rootPackageJson); + + // If JS workspace didn't find multiple apps, try non-JS monorepo detection + if (apps.length <= 1) { + const nonJs = await detectNonJsMonorepo(repoPath, files); + if (nonJs.apps.length > 1) { + apps = nonJs.apps; + if (nonJs.type) analysis.workspaceType = nonJs.type; + if (nonJs.patterns) analysis.workspacePatterns = nonJs.patterns; + } + } + + if (workspace && files.includes("turbo.json") && apps.length > 1) { + analysis.workspaceType = "turborepo"; + } + + if (files.includes("nx.json") && apps.length > 1 && analysis.workspaceType !== "turborepo") { + analysis.workspaceType = "nx"; + } + + analysis.apps = apps; + analysis.isMonorepo = apps.length > 1; + + analysis.languages = unique(analysis.languages); + analysis.frameworks = unique(analysis.frameworks); + + // Detect areas from apps and folder heuristics + analysis.areas = await detectAreas(repoPath, analysis); + + return analysis; +} diff --git a/packages/core/src/services/analyzer/types.ts b/packages/core/src/services/analyzer/types.ts new file mode 100644 index 0000000..5e0365f --- /dev/null +++ b/packages/core/src/services/analyzer/types.ts @@ -0,0 +1,54 @@ +export type RepoApp = { + name: string; + path: string; + ecosystem?: "node" | "rust" | "go" | "dotnet" | "java" | "python" | "ruby" | "php"; + manifestPath?: string; + packageJsonPath: string; + scripts: Record; + hasTsConfig: boolean; +}; + +export type Area = { + name: string; + description?: string; + applyTo: string | string[]; + path?: string; + ecosystem?: RepoApp["ecosystem"]; + source: "auto" | "config"; + scripts?: Record; + hasTsConfig?: boolean; + parentArea?: string; + workingDirectory?: string; +}; + +export type RepoAnalysis = { + path: string; + isGitRepo: boolean; + languages: string[]; + frameworks: string[]; + packageManager?: string; + isMonorepo?: boolean; + workspaceType?: + | "npm" + | "pnpm" + | "yarn" + | "cargo" + | "go" + | "dotnet" + | "gradle" + | "maven" + | "bazel" + | "nx" + | "pants" + | "turborepo"; + workspacePatterns?: string[]; + apps?: RepoApp[]; + areas?: Area[]; +}; + +export const PACKAGE_MANAGERS: Array<{ file: string; name: string }> = [ + { file: "pnpm-lock.yaml", name: "pnpm" }, + { file: "yarn.lock", name: "yarn" }, + { file: "package-lock.json", name: "npm" }, + { file: "bun.lockb", name: "bun" } +]; diff --git a/packages/core/src/services/analyzer/workspaces.ts b/packages/core/src/services/analyzer/workspaces.ts new file mode 100644 index 0000000..edf4862 --- /dev/null +++ b/packages/core/src/services/analyzer/workspaces.ts @@ -0,0 +1,146 @@ +import path from "path"; + +import { safeReadDir } from "../../utils/fs"; + +import { isScannableDirectory } from "./apps"; +import { areasFromHeuristics } from "./areas"; +import type { AgentrcConfigArea, AgentrcConfigWorkspace } from "./config"; +import type { Area } from "./types"; + +const WORKSPACE_SCAN_MAX_DEPTH = 3; +const WORKSPACE_SKIP_DIRS = new Set([ + "node_modules", + ".git", + ".hg", + "target", + "build", + "dist", + "out", + "vendor", + "coverage", + "__pycache__", + ".cache" +]); + +/** + * Detect workspaces by scanning for `.vscode` folders (indicating directories + * that developers open as VS Code workspaces) and by grouping auto-detected + * areas that share a common parent directory. + */ +export async function detectWorkspaces( + repoPath: string, + areas: Area[] +): Promise { + const resolvedRoot = path.resolve(repoPath); + const workspaces = new Map(); + + // Strategy 1: Scan for .vscode folders as workspace markers + const vscodeDirs = await findVSCodeDirs(repoPath, repoPath, WORKSPACE_SCAN_MAX_DEPTH); + for (const vsDir of vscodeDirs) { + // The workspace is the parent of the .vscode folder + const wsAbs = path.dirname(vsDir); + const wsRel = path.relative(repoPath, wsAbs).replace(/\\/gu, "/"); + if (!wsRel || wsRel === ".") continue; // skip repo root + + const wsResolved = path.resolve(wsAbs); + if (!wsResolved.startsWith(resolvedRoot + path.sep)) continue; + + // Find areas that fall within this workspace + const wsAreas = areasWithinDir(wsRel, areas); + if (wsAreas.length === 0) { + // Run heuristic detection scoped to this workspace directory + const scopedAreas = await areasFromHeuristics(wsAbs); + if (scopedAreas.length === 0) continue; + const configAreas = scopedAreas.map((a) => ({ + name: a.name, + applyTo: Array.isArray(a.applyTo) ? a.applyTo : a.applyTo, + description: a.description + })); + workspaces.set(wsRel, { name: path.basename(wsRel), path: wsRel, areas: configAreas }); + } else { + const configAreas = wsAreas.map((a) => toWorkspaceRelativeArea(wsRel, a)); + workspaces.set(wsRel, { name: path.basename(wsRel), path: wsRel, areas: configAreas }); + } + } + + // Strategy 2: Group areas by common parent directory (2+ siblings → workspace) + const parentGroups = new Map(); + for (const area of areas) { + if (!area.path) continue; + const rel = path.relative(repoPath, area.path).replace(/\\/gu, "/"); + const segments = rel.split("/"); + if (segments.length < 2) continue; // top-level dir — not a nested workspace + + const parentRel = segments.slice(0, -1).join("/"); + if (workspaces.has(parentRel)) continue; // already found via .vscode + + if (!parentGroups.has(parentRel)) parentGroups.set(parentRel, []); + parentGroups.get(parentRel)!.push(area); + } + + for (const [parentRel, grouped] of parentGroups) { + if (grouped.length < 2) continue; + + const parentAbs = path.resolve(repoPath, parentRel); + const parentResolved = path.resolve(parentAbs); + if (!parentResolved.startsWith(resolvedRoot + path.sep)) continue; + + const configAreas = grouped.map((a) => toWorkspaceRelativeArea(parentRel, a)); + workspaces.set(parentRel, { + name: path.basename(parentRel), + path: parentRel, + areas: configAreas + }); + } + + return Array.from(workspaces.values()); +} + +async function findVSCodeDirs( + repoPath: string, + dir: string, + maxDepth: number, + depth = 0 +): Promise { + if (depth >= maxDepth) return []; + const results: string[] = []; + let entries: string[]; + try { + entries = await safeReadDir(dir); + } catch { + return []; + } + + for (const entry of entries) { + if (entry === ".vscode") { + results.push(path.join(dir, entry)); + continue; + } + if (entry.startsWith(".") || WORKSPACE_SKIP_DIRS.has(entry)) continue; + const fullPath = path.join(dir, entry); + if (await isScannableDirectory(repoPath, fullPath)) { + results.push(...(await findVSCodeDirs(repoPath, fullPath, maxDepth, depth + 1))); + } + } + + return results; +} + +function areasWithinDir(wsRel: string, areas: Area[]): Area[] { + const prefix = wsRel + "/"; + return areas.filter((a) => { + const patterns = Array.isArray(a.applyTo) ? a.applyTo : [a.applyTo]; + return patterns.some((p) => p.startsWith(prefix)); + }); +} + +function toWorkspaceRelativeArea(wsRel: string, area: Area): AgentrcConfigArea { + const prefix = wsRel + "/"; + const patterns = Array.isArray(area.applyTo) ? area.applyTo : [area.applyTo]; + const relPatterns = patterns.map((p) => (p.startsWith(prefix) ? p.slice(prefix.length) : p)); + return { + name: area.name, + applyTo: relPatterns.length === 1 ? relPatterns[0] : relPatterns, + description: area.description + }; +} diff --git a/src/services/azureDevops.ts b/packages/core/src/services/azureDevops.ts similarity index 100% rename from src/services/azureDevops.ts rename to packages/core/src/services/azureDevops.ts diff --git a/src/services/batch.ts b/packages/core/src/services/batch.ts similarity index 100% rename from src/services/batch.ts rename to packages/core/src/services/batch.ts diff --git a/src/services/copilot.ts b/packages/core/src/services/copilot.ts similarity index 100% rename from src/services/copilot.ts rename to packages/core/src/services/copilot.ts diff --git a/src/services/copilotSdk.ts b/packages/core/src/services/copilotSdk.ts similarity index 100% rename from src/services/copilotSdk.ts rename to packages/core/src/services/copilotSdk.ts diff --git a/src/services/evalScaffold.ts b/packages/core/src/services/evalScaffold.ts similarity index 100% rename from src/services/evalScaffold.ts rename to packages/core/src/services/evalScaffold.ts diff --git a/src/services/evaluator.ts b/packages/core/src/services/evaluator.ts similarity index 100% rename from src/services/evaluator.ts rename to packages/core/src/services/evaluator.ts diff --git a/src/services/generator.ts b/packages/core/src/services/generator.ts similarity index 100% rename from src/services/generator.ts rename to packages/core/src/services/generator.ts diff --git a/src/services/git.ts b/packages/core/src/services/git.ts similarity index 100% rename from src/services/git.ts rename to packages/core/src/services/git.ts diff --git a/src/services/github.ts b/packages/core/src/services/github.ts similarity index 100% rename from src/services/github.ts rename to packages/core/src/services/github.ts diff --git a/src/services/instructions.ts b/packages/core/src/services/instructions.ts similarity index 100% rename from src/services/instructions.ts rename to packages/core/src/services/instructions.ts diff --git a/src/services/policy.ts b/packages/core/src/services/policy.ts similarity index 100% rename from src/services/policy.ts rename to packages/core/src/services/policy.ts diff --git a/src/services/policy/adapter.ts b/packages/core/src/services/policy/adapter.ts similarity index 100% rename from src/services/policy/adapter.ts rename to packages/core/src/services/policy/adapter.ts diff --git a/src/services/policy/compiler.ts b/packages/core/src/services/policy/compiler.ts similarity index 100% rename from src/services/policy/compiler.ts rename to packages/core/src/services/policy/compiler.ts diff --git a/src/services/policy/engine.ts b/packages/core/src/services/policy/engine.ts similarity index 100% rename from src/services/policy/engine.ts rename to packages/core/src/services/policy/engine.ts diff --git a/src/services/policy/index.ts b/packages/core/src/services/policy/index.ts similarity index 100% rename from src/services/policy/index.ts rename to packages/core/src/services/policy/index.ts diff --git a/src/services/policy/loader.ts b/packages/core/src/services/policy/loader.ts similarity index 100% rename from src/services/policy/loader.ts rename to packages/core/src/services/policy/loader.ts diff --git a/src/services/policy/shadow.ts b/packages/core/src/services/policy/shadow.ts similarity index 100% rename from src/services/policy/shadow.ts rename to packages/core/src/services/policy/shadow.ts diff --git a/src/services/policy/types.ts b/packages/core/src/services/policy/types.ts similarity index 100% rename from src/services/policy/types.ts rename to packages/core/src/services/policy/types.ts diff --git a/src/services/readiness.ts b/packages/core/src/services/readiness.ts similarity index 100% rename from src/services/readiness.ts rename to packages/core/src/services/readiness.ts diff --git a/packages/core/src/services/readiness/checkers.ts b/packages/core/src/services/readiness/checkers.ts new file mode 100644 index 0000000..9007ed1 --- /dev/null +++ b/packages/core/src/services/readiness/checkers.ts @@ -0,0 +1,302 @@ +import fs from "fs/promises"; +import path from "path"; + +import { fileExists, safeReadDir, readJson } from "../../utils/fs"; + +import type { InstructionConsistencyResult, ReadinessContext } from "./types"; + +export function hasAnyFile(files: string[], candidates: string[]): boolean { + return candidates.some((candidate) => files.includes(candidate)); +} + +export async function hasReadme(repoPath: string): Promise { + const files = await safeReadDir(repoPath); + return files.some( + (file) => file.toLowerCase() === "readme.md" || file.toLowerCase() === "readme" + ); +} + +export async function hasLintConfig(repoPath: string): Promise { + return hasAnyFile(await safeReadDir(repoPath), [ + "eslint.config.js", + "eslint.config.mjs", + ".eslintrc", + ".eslintrc.js", + ".eslintrc.cjs", + ".eslintrc.json", + ".eslintrc.yml", + ".eslintrc.yaml", + "biome.json", + "biome.jsonc", + ".prettierrc", + ".prettierrc.json", + ".prettierrc.js", + ".prettierrc.cjs", + "prettier.config.js", + "prettier.config.cjs" + ]); +} + +export async function hasFormatterConfig(repoPath: string): Promise { + return hasAnyFile(await safeReadDir(repoPath), [ + "biome.json", + "biome.jsonc", + ".prettierrc", + ".prettierrc.json", + ".prettierrc.js", + ".prettierrc.cjs", + "prettier.config.js", + "prettier.config.cjs" + ]); +} + +export async function hasTypecheckConfig(repoPath: string): Promise { + return hasAnyFile(await safeReadDir(repoPath), [ + "tsconfig.json", + "tsconfig.base.json", + "pyproject.toml", + "mypy.ini" + ]); +} + +export async function hasGithubWorkflows(repoPath: string): Promise { + return fileExists(path.join(repoPath, ".github", "workflows")); +} + +export async function hasCodeowners(repoPath: string): Promise { + const root = await fileExists(path.join(repoPath, "CODEOWNERS")); + const github = await fileExists(path.join(repoPath, ".github", "CODEOWNERS")); + return root || github; +} + +export async function hasLicense(repoPath: string): Promise { + const files = await safeReadDir(repoPath); + return files.some((file) => file.toLowerCase().startsWith("license")); +} + +export async function hasPullRequestTemplate(repoPath: string): Promise { + const direct = await fileExists(path.join(repoPath, ".github", "PULL_REQUEST_TEMPLATE.md")); + if (direct) return true; + const dir = path.join(repoPath, ".github", "PULL_REQUEST_TEMPLATE"); + try { + const entries = await fs.readdir(dir); + return entries.some((entry) => entry.toLowerCase().endsWith(".md")); + } catch { + return false; + } +} + +export async function hasPrecommitConfig(repoPath: string): Promise { + const precommit = await fileExists(path.join(repoPath, ".pre-commit-config.yaml")); + if (precommit) return true; + return fileExists(path.join(repoPath, ".husky")); +} + +export async function hasArchitectureDoc(repoPath: string): Promise { + const files = await safeReadDir(repoPath); + if (files.some((file) => file.toLowerCase() === "architecture.md")) return true; + return fileExists(path.join(repoPath, "docs", "architecture.md")); +} + +export async function hasCustomInstructions(repoPath: string): Promise { + const found: string[] = []; + const candidates = [ + ".github/copilot-instructions.md", + "CLAUDE.md", + ".claude/CLAUDE.md", + "AGENTS.md", + ".github/AGENTS.md", + ".cursorrules", + ".cursorignore", + ".windsurfrules", + ".github/instructions.md", + "copilot-instructions.md" + ]; + for (const candidate of candidates) { + if (await fileExists(path.join(repoPath, candidate))) { + found.push(candidate); + } + } + return found; +} + +export async function hasFileBasedInstructions(repoPath: string): Promise { + const instructionsDir = path.join(repoPath, ".github", "instructions"); + try { + const entries = await fs.readdir(instructionsDir); + return entries + .filter((e) => e.endsWith(".instructions.md")) + .map((e) => `.github/instructions/${e}`); + } catch { + return []; + } +} + +/** + * Jaccard similarity on normalized line sets. + * Returns 1.0 for identical (after normalization), 0.0 for completely disjoint. + */ +export function contentSimilarity(a: string, b: string): number { + const normalize = (s: string) => + new Set( + s + .toLowerCase() + .split("\n") + .map((l) => l.trim().replace(/\s+/gu, " ")) + .filter((l) => l.length > 0) + ); + const setA = normalize(a); + const setB = normalize(b); + if (setA.size === 0 && setB.size === 0) return 1; + let intersection = 0; + for (const line of setA) { + if (setB.has(line)) intersection++; + } + const union = new Set([...setA, ...setB]).size; + return union === 0 ? 1 : intersection / union; +} + +/** + * Check whether multiple instruction files in a repo are consistent. + * Files sharing the same realpath (symlinks) are treated as unified. + * For distinct files, content is compared via Jaccard similarity. + */ +export async function checkInstructionConsistency( + repoPath: string, + foundFiles: string[] +): Promise { + if (foundFiles.length <= 1) { + return { unified: true, files: foundFiles }; + } + + // Group files by their real path (symlinks collapse) + const realPathMap = new Map(); + for (const file of foundFiles) { + const fullPath = path.join(repoPath, file); + try { + const real = await fs.realpath(fullPath); + const group = realPathMap.get(real) ?? []; + group.push(file); + realPathMap.set(real, group); + } catch { + // If realpath fails, treat as unique + realPathMap.set(fullPath, [file]); + } + } + + const groups = [...realPathMap.values()]; + // All files resolve to the same real path → unified via symlinks + if (groups.length <= 1) { + return { unified: true, files: foundFiles }; + } + + // Read content from one representative file per group and compare pairwise + const contents: string[] = []; + for (const group of groups) { + try { + contents.push(await fs.readFile(path.join(repoPath, group[0]), "utf8")); + } catch { + contents.push(""); + } + } + + // Compute minimum pairwise similarity + let minSimilarity = 1; + for (let i = 0; i < contents.length; i++) { + for (let j = i + 1; j < contents.length; j++) { + minSimilarity = Math.min(minSimilarity, contentSimilarity(contents[i], contents[j])); + } + } + + return { + unified: minSimilarity >= 0.9, + files: foundFiles, + similarity: Math.round(minSimilarity * 100) / 100 + }; +} + +export async function hasMcpConfig(repoPath: string): Promise { + const found: string[] = []; + // Check .vscode/mcp.json + if (await fileExists(path.join(repoPath, ".vscode", "mcp.json"))) { + found.push(".vscode/mcp.json"); + } + // Check root mcp.json + if (await fileExists(path.join(repoPath, "mcp.json"))) { + found.push("mcp.json"); + } + // Check .vscode/settings.json for MCP section + const settings = await readJson(path.join(repoPath, ".vscode", "settings.json")); + if (settings && (settings["mcp"] || settings["github.copilot.chat.mcp.enabled"])) { + found.push(".vscode/settings.json (mcp section)"); + } + // Check .claude/mcp.json + if (await fileExists(path.join(repoPath, ".claude", "mcp.json"))) { + found.push(".claude/mcp.json"); + } + return found; +} + +export async function hasCustomAgents(repoPath: string): Promise { + const found: string[] = []; + const agentDirs = [".github/agents", ".copilot/agents", ".github/copilot/agents"]; + for (const dir of agentDirs) { + if (await fileExists(path.join(repoPath, dir))) { + found.push(dir); + } + } + // Check for agent config files + const agentFiles = [".github/copilot-agents.yml", ".github/copilot-agents.yaml"]; + for (const agentFile of agentFiles) { + if (await fileExists(path.join(repoPath, agentFile))) { + found.push(agentFile); + } + } + return found; +} + +export async function hasCopilotSkills(repoPath: string): Promise { + const found: string[] = []; + const skillDirs = [ + ".copilot/skills", + ".github/skills", + ".claude/skills", + ".github/copilot/skills" + ]; + for (const dir of skillDirs) { + if (await fileExists(path.join(repoPath, dir))) { + found.push(dir); + } + } + return found; +} + +export async function readAllDependencies(context: ReadinessContext): Promise { + const dependencies: string[] = []; + const apps = context.apps.length ? context.apps : []; + for (const app of apps) { + if (!app.packageJsonPath) continue; + const pkg = await readJson(app.packageJsonPath); + const deps = (pkg?.dependencies ?? {}) as Record; + const devDeps = (pkg?.devDependencies ?? {}) as Record; + dependencies.push( + ...Object.keys({ + ...deps, + ...devDeps + }) + ); + } + + if (!apps.length && context.rootPackageJson) { + const rootDeps = (context.rootPackageJson.dependencies ?? {}) as Record; + const rootDevDeps = (context.rootPackageJson.devDependencies ?? {}) as Record; + dependencies.push( + ...Object.keys({ + ...rootDeps, + ...rootDevDeps + }) + ); + } + + return Array.from(new Set(dependencies)); +} diff --git a/packages/core/src/services/readiness/criteria.ts b/packages/core/src/services/readiness/criteria.ts new file mode 100644 index 0000000..af6dfac --- /dev/null +++ b/packages/core/src/services/readiness/criteria.ts @@ -0,0 +1,538 @@ +import path from "path"; + +import { fileExists, readJson } from "../../utils/fs"; +import { sanitizeAreaName } from "../analyzer"; + +import { + hasLintConfig, + hasTypecheckConfig, + hasGithubWorkflows, + hasFormatterConfig, + hasCodeowners, + hasLicense, + hasReadme, + hasAnyFile, + hasCustomInstructions, + hasFileBasedInstructions, + hasMcpConfig, + hasCustomAgents, + hasCopilotSkills, + readAllDependencies, + checkInstructionConsistency +} from "./checkers"; +import type { ReadinessCriterion } from "./types"; + +export function buildCriteria(): ReadinessCriterion[] { + return [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await hasLintConfig(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing ESLint/Biome/Prettier configuration.", + evidence: ["eslint.config.js", ".eslintrc", "biome.json", ".prettierrc"] + }; + } + }, + { + id: "typecheck-config", + title: "Type checking configured", + pillar: "style-validation", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await hasTypecheckConfig(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing type checking config (tsconfig or equivalent).", + evidence: ["tsconfig.json", "pyproject.toml", "mypy.ini"] + }; + } + }, + { + id: "build-script", + title: "Build script present", + pillar: "build-system", + level: 1, + scope: "app", + impact: "high", + effort: "low", + check: async (_context, app) => { + const found = Boolean(app?.scripts?.build); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing build script in package.json." + }; + } + }, + { + id: "ci-config", + title: "CI workflow configured", + pillar: "build-system", + level: 2, + scope: "repo", + impact: "high", + effort: "medium", + check: async (context) => { + const found = await hasGithubWorkflows(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing .github/workflows CI configuration.", + evidence: [".github/workflows"] + }; + } + }, + { + id: "test-script", + title: "Test script present", + pillar: "testing", + level: 1, + scope: "app", + impact: "high", + effort: "low", + check: async (_context, app) => { + const found = Boolean(app?.scripts?.test); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing test script in package.json." + }; + } + }, + { + id: "readme", + title: "README present", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await hasReadme(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing README documentation.", + evidence: ["README.md"] + }; + } + }, + { + id: "contributing", + title: "CONTRIBUTING guide present", + pillar: "documentation", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await fileExists(path.join(context.repoPath, "CONTRIBUTING.md")); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing CONTRIBUTING.md for contributor workflows." + }; + } + }, + { + id: "lockfile", + title: "Lockfile present", + pillar: "dev-environment", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = hasAnyFile(context.rootFiles, [ + "pnpm-lock.yaml", + "yarn.lock", + "package-lock.json", + "bun.lockb" + ]); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing package manager lockfile." + }; + } + }, + { + id: "env-example", + title: "Environment example present", + pillar: "dev-environment", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = hasAnyFile(context.rootFiles, [".env.example", ".env.sample"]); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing .env.example or .env.sample for setup guidance." + }; + } + }, + { + id: "format-config", + title: "Formatter configured", + pillar: "code-quality", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await hasFormatterConfig(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing Prettier/Biome formatting config." + }; + } + }, + { + id: "codeowners", + title: "CODEOWNERS present", + pillar: "security-governance", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await hasCodeowners(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing CODEOWNERS file." + }; + } + }, + { + id: "license", + title: "LICENSE present", + pillar: "security-governance", + level: 1, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await hasLicense(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing LICENSE file." + }; + } + }, + { + id: "security-policy", + title: "Security policy present", + pillar: "security-governance", + level: 3, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await fileExists(path.join(context.repoPath, "SECURITY.md")); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing SECURITY.md policy." + }; + } + }, + { + id: "dependabot", + title: "Dependabot configured", + pillar: "security-governance", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const found = await fileExists(path.join(context.repoPath, ".github", "dependabot.yml")); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing .github/dependabot.yml configuration." + }; + } + }, + { + id: "observability", + title: "Observability tooling present", + pillar: "observability", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const deps = await readAllDependencies(context); + const has = deps.some((dep) => + ["@opentelemetry/api", "@opentelemetry/sdk", "pino", "winston", "bunyan"].includes(dep) + ); + return { + status: has ? "pass" : "fail", + reason: "No observability dependencies detected (OpenTelemetry/logging)." + }; + } + }, + { + id: "custom-instructions", + title: "Custom AI instructions or agent guidance", + pillar: "ai-tooling", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const rootFound = await hasCustomInstructions(context.repoPath); + if (rootFound.length === 0) { + return { + status: "fail", + reason: + "Missing custom AI instructions (e.g. copilot-instructions.md, CLAUDE.md, AGENTS.md, .cursorrules).", + evidence: [ + "copilot-instructions.md", + "CLAUDE.md", + "AGENTS.md", + ".cursorrules", + ".github/copilot-instructions.md" + ] + }; + } + + // Check for file-based instructions (.github/instructions/*.instructions.md) + const fileBasedInstructions = await hasFileBasedInstructions(context.repoPath); + const areas = context.analysis.areas ?? []; + + // For monorepos or repos with detected areas, check coverage + if (areas.length > 0) { + if (fileBasedInstructions.length === 0) { + return { + status: "pass", + reason: `Root instructions found, but no file-based instructions for ${areas.length} detected areas. Run \`agentrc instructions --areas\` to generate.`, + evidence: [...rootFound, ...areas.map((a) => `${a.name}: missing .instructions.md`)] + }; + } + return { + status: "pass", + reason: `Root + ${fileBasedInstructions.length} file-based instruction(s) found.`, + evidence: [...rootFound, ...fileBasedInstructions] + }; + } + + // For monorepos without areas, check per-app instructions (legacy behavior) + if (context.analysis.isMonorepo && context.apps.length > 1) { + const appsMissing: string[] = []; + for (const app of context.apps) { + const appFound = await hasCustomInstructions(app.path); + if (appFound.length === 0) { + appsMissing.push(app.name); + } + } + if (appsMissing.length > 0) { + return { + status: "pass", + reason: `Root instructions found, but ${appsMissing.length}/${context.apps.length} apps missing their own: ${appsMissing.join(", ")}`, + evidence: [ + ...rootFound, + ...appsMissing.map((name) => `${name}: missing app-level instructions`) + ] + }; + } + } + + return { + status: "pass", + evidence: rootFound + }; + } + }, + { + id: "instructions-consistency", + title: "AI instruction files are consistent", + pillar: "ai-tooling", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const rootFound = await hasCustomInstructions(context.repoPath); + if (rootFound.length <= 1) { + return { status: "skip", reason: "Fewer than 2 instruction files found." }; + } + const result = await checkInstructionConsistency(context.repoPath, rootFound); + if (result.unified) { + return { + status: "pass", + reason: `${rootFound.length} instruction files are consistent.`, + evidence: rootFound + }; + } + return { + status: "fail", + reason: `${rootFound.length} instruction files are diverging (${result.similarity !== undefined ? `${Math.round(result.similarity * 100)}% similar` : "different content"}). Consider consolidating or symlinking them.`, + evidence: rootFound + }; + } + }, + { + id: "mcp-config", + title: "MCP configuration present", + pillar: "ai-tooling", + level: 2, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await hasMcpConfig(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "Missing MCP (Model Context Protocol) configuration (e.g. .vscode/mcp.json).", + evidence: + found.length > 0 + ? found + : [".vscode/mcp.json", ".vscode/settings.json (mcp section)", "mcp.json"] + }; + } + }, + { + id: "custom-agents", + title: "Custom AI agents configured", + pillar: "ai-tooling", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const found = await hasCustomAgents(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "No custom AI agents configured (e.g. .github/agents/, .copilot/agents/).", + evidence: + found.length > 0 + ? found + : [".github/agents/", ".copilot/agents/", ".github/copilot/agents/"] + }; + } + }, + { + id: "copilot-skills", + title: "Copilot/Claude skills present", + pillar: "ai-tooling", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const found = await hasCopilotSkills(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "No Copilot or Claude skills found (e.g. .copilot/skills/, .github/skills/).", + evidence: + found.length > 0 ? found : [".copilot/skills/", ".github/skills/", ".claude/skills/"] + }; + } + }, + // ── Area-scoped criteria (only run when areaPath is set) ── + { + id: "area-readme", + title: "Area README present", + pillar: "documentation", + level: 1, + scope: "area", + impact: "medium", + effort: "low", + check: async (context) => { + if (!context.areaPath || !context.areaFiles) { + return { status: "skip", reason: "No area context." }; + } + const found = context.areaFiles.some( + (f) => f.toLowerCase() === "readme.md" || f.toLowerCase() === "readme" + ); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing README in area directory." + }; + } + }, + { + id: "area-build-script", + title: "Area build script present", + pillar: "build-system", + level: 1, + scope: "area", + impact: "high", + effort: "low", + check: async (context, _app, area) => { + if (!context.areaPath || !context.areaFiles) { + return { status: "skip", reason: "No area context." }; + } + // Check area.scripts from enriched Area type + if (area?.scripts?.build) { + return { status: "pass" }; + } + // Fallback: check for package.json with build script in area + const pkgPath = path.join(context.areaPath, "package.json"); + const pkg = await readJson(pkgPath); + const scripts = (pkg?.scripts ?? {}) as Record; + const found = Boolean(scripts.build); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing build script in area." + }; + } + }, + { + id: "area-test-script", + title: "Area test script present", + pillar: "testing", + level: 1, + scope: "area", + impact: "high", + effort: "low", + check: async (context, _app, area) => { + if (!context.areaPath || !context.areaFiles) { + return { status: "skip", reason: "No area context." }; + } + if (area?.scripts?.test) { + return { status: "pass" }; + } + const pkgPath = path.join(context.areaPath, "package.json"); + const pkg = await readJson(pkgPath); + const scripts = (pkg?.scripts ?? {}) as Record; + const found = Boolean(scripts.test); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing test script in area." + }; + } + }, + { + id: "area-instructions", + title: "Area-specific instructions present", + pillar: "ai-tooling", + level: 2, + scope: "area", + impact: "high", + effort: "low", + check: async (context, _app, area) => { + if (!area) { + return { status: "skip", reason: "No area context." }; + } + const sanitized = sanitizeAreaName(area.name); + const instructionPath = path.join( + context.repoPath, + ".github", + "instructions", + `${sanitized}.instructions.md` + ); + const found = await fileExists(instructionPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : `Missing .github/instructions/${sanitized}.instructions.md` + }; + } + } + ]; +} diff --git a/packages/core/src/services/readiness/extras.ts b/packages/core/src/services/readiness/extras.ts new file mode 100644 index 0000000..8c7be56 --- /dev/null +++ b/packages/core/src/services/readiness/extras.ts @@ -0,0 +1,73 @@ +import path from "path"; + +import { fileExists } from "../../utils/fs"; +import type { ExtraDefinition } from "../policy"; + +import { hasPullRequestTemplate, hasPrecommitConfig, hasArchitectureDoc } from "./checkers"; +import type { ReadinessContext, ReadinessExtraResult } from "./types"; + +export function buildExtras(): ExtraDefinition[] { + return [ + { + id: "agents-doc", + title: "AGENTS.md present", + check: async (context) => { + const found = await fileExists(path.join(context.repoPath, "AGENTS.md")); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing AGENTS.md to guide coding agents." + }; + } + }, + { + id: "pr-template", + title: "Pull request template present", + check: async (context) => { + const found = await hasPullRequestTemplate(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing PR template for consistent reviews." + }; + } + }, + { + id: "pre-commit", + title: "Pre-commit hooks configured", + check: async (context) => { + const found = await hasPrecommitConfig(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing pre-commit or Husky configuration for fast feedback." + }; + } + }, + { + id: "architecture-doc", + title: "Architecture guide present", + check: async (context) => { + const found = await hasArchitectureDoc(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing architecture documentation." + }; + } + } + ]; +} + +export async function runExtras( + context: ReadinessContext, + extraDefs: ExtraDefinition[] +): Promise { + const results: ReadinessExtraResult[] = []; + for (const def of extraDefs) { + const result = await def.check(context); + results.push({ + id: def.id, + title: def.title, + status: result.status, + reason: result.reason + }); + } + return results; +} diff --git a/packages/core/src/services/readiness/index.ts b/packages/core/src/services/readiness/index.ts new file mode 100644 index 0000000..e9c91f2 --- /dev/null +++ b/packages/core/src/services/readiness/index.ts @@ -0,0 +1,291 @@ +import path from "path"; + +import { safeReadDir, readJson } from "../../utils/fs"; +import { analyzeRepo, loadAgentrcConfig } from "../analyzer"; +import type { ExtraDefinition, PolicyConfig } from "../policy"; +import { loadPolicy, resolveChain } from "../policy"; +import { executePlugins } from "../policy/engine"; +import { loadPluginChain } from "../policy/loader"; +import type { PolicyContext } from "../policy/types"; + +import { buildCriteria } from "./criteria"; +import { buildExtras, runExtras } from "./extras"; +import { summarizePillars, summarizeLevels } from "./scoring"; +import type { + ReadinessContext, + ReadinessCriterion, + ReadinessCriterionResult, + ReadinessReport, + ReadinessStatus, + AreaReadinessReport +} from "./types"; + +// ── Re-exports: keep the public API surface identical ── +export type { + ReadinessPillar, + PillarGroup, + ReadinessScope, + ReadinessStatus, + ReadinessCriterionResult, + ReadinessExtraResult, + ReadinessPillarSummary, + ReadinessLevelSummary, + AreaReadinessReport, + ReadinessReport, + ReadinessContext, + ReadinessCriterion, + CheckResult, + InstructionConsistencyResult +} from "./types"; +export { PILLAR_GROUPS, PILLAR_GROUP_NAMES } from "./types"; +export { groupPillars, getLevelName, getLevelDescription } from "./scoring"; +export { buildCriteria } from "./criteria"; +export { buildExtras } from "./extras"; +export { contentSimilarity, checkInstructionConsistency } from "./checkers"; + +type ReadinessOptions = { + repoPath: string; + includeExtras?: boolean; + perArea?: boolean; + policies?: string[]; + shadow?: boolean; +}; + +export async function runReadinessReport(options: ReadinessOptions): Promise { + const repoPath = options.repoPath; + const analysis = await analyzeRepo(repoPath); + const rootFiles = await safeReadDir(repoPath); + const rootPackageJson = await readJson(path.join(repoPath, "package.json")); + const apps = analysis.apps?.length ? analysis.apps : []; + + const context: ReadinessContext = { + repoPath, + analysis, + apps, + rootFiles, + rootPackageJson + }; + + // ── Policy resolution ── + let policySources = options.policies; + let isConfigSourced = false; + if (!policySources?.length) { + const agentrcConfig = await loadAgentrcConfig(repoPath); + if (agentrcConfig?.policies?.length) { + policySources = agentrcConfig.policies; + isConfigSourced = true; + } + } + + const baseCriteria = buildCriteria(); + const baseExtras = buildExtras(); + let resolvedCriteria: ReadinessCriterion[]; + let resolvedExtras: ExtraDefinition[]; + let passRateThreshold = 0.8; + let policyInfo: { chain: string[]; criteriaCount: number } | undefined; + + if (policySources?.length) { + const policyConfigs: PolicyConfig[] = []; + for (const source of policySources) { + policyConfigs.push(await loadPolicy(source, { jsonOnly: isConfigSourced })); + } + const resolved = resolveChain(baseCriteria, baseExtras, policyConfigs); + resolvedCriteria = resolved.criteria; + resolvedExtras = resolved.extras; + passRateThreshold = resolved.thresholds.passRate; + policyInfo = { chain: resolved.chain, criteriaCount: resolved.criteria.length }; + } else { + resolvedCriteria = baseCriteria; + resolvedExtras = baseExtras; + } + + const criteriaResults: ReadinessCriterionResult[] = []; + + for (const criterion of resolvedCriteria) { + if (criterion.scope === "repo") { + const result = await criterion.check(context); + criteriaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status: result.status, + reason: result.reason, + evidence: result.evidence + }); + continue; + } + + if (criterion.scope === "area") { + if (!options.perArea) continue; + const areas = analysis.areas ?? []; + if (areas.length === 0) continue; + criteriaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status: "skip", + reason: "Run with --per-area for area breakdown." + }); + continue; + } + + const appResults = await Promise.all( + apps.map(async (app) => ({ + app, + result: await criterion.check(context, app) + })) + ); + + if (!appResults.length) { + criteriaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status: "skip", + reason: "No application packages detected." + }); + continue; + } + + const passed = appResults.filter((entry) => entry.result.status === "pass").length; + const total = appResults.length; + const passRate = total ? passed / total : 0; + const status: ReadinessStatus = passRate >= passRateThreshold ? "pass" : "fail"; + const failures = appResults + .filter((entry) => entry.result.status !== "pass") + .map((entry) => entry.app.name); + + criteriaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status, + reason: status === "pass" ? undefined : `Only ${passed}/${total} apps pass this check.`, + passRate, + appSummary: { passed, total }, + appFailures: failures + }); + } + + // Per-area breakdown + let areaReports: AreaReadinessReport[] | undefined; + const areas = analysis.areas ?? []; + + if (options.perArea && areas.length > 0) { + const areaCriteria = resolvedCriteria.filter((c) => c.scope === "area"); + areaReports = []; + + for (const area of areas) { + if (!area.path) continue; + const areaFiles = await safeReadDir(area.path); + const areaContext: ReadinessContext = { + ...context, + areaPath: area.path, + areaFiles + }; + + const areaResults: ReadinessCriterionResult[] = []; + for (const criterion of areaCriteria) { + const result = await criterion.check(areaContext, undefined, area); + areaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status: result.status, + reason: result.reason, + evidence: result.evidence + }); + } + + const areaPillars = summarizePillars(areaResults); + areaReports.push({ area, criteria: areaResults, pillars: areaPillars }); + } + + // Update aggregate area criteria in main results + for (const criterion of criteriaResults) { + if (criterion.scope !== "area") continue; + const perAreaResults = areaReports + .map((ar) => ar.criteria.find((c) => c.id === criterion.id)) + .filter(Boolean) as ReadinessCriterionResult[]; + if (!perAreaResults.length) continue; + + const passed = perAreaResults.filter((r) => r.status === "pass").length; + const total = perAreaResults.length; + const passRate = total ? passed / total : 0; + criterion.status = passRate >= passRateThreshold ? "pass" : "fail"; + criterion.reason = + criterion.status === "pass" ? undefined : `Only ${passed}/${total} areas pass this check.`; + criterion.passRate = passRate; + criterion.areaSummary = { passed, total }; + criterion.areaFailures = areaReports + .filter((ar) => ar.criteria.find((c) => c.id === criterion.id)?.status !== "pass") + .map((ar) => ar.area.name); + } + } + + // Compute summaries after area aggregation so they reflect final statuses + const pillars = summarizePillars(criteriaResults); + const levels = summarizeLevels(criteriaResults, passRateThreshold); + const achievedLevel = levels + .filter((level) => level.achieved) + .reduce((acc, level) => Math.max(acc, level.level), 0); + + const extras = options.includeExtras === false ? [] : await runExtras(context, resolvedExtras); + + // ── Plugin engine: run shadow comparison when opts.shadow is enabled ── + let engine: ReadinessReport["engine"]; + if (options.shadow) { + const policyCtx: PolicyContext = { + repoPath, + rootFiles, + rootPackageJson, + cache: new Map(), + analysis, + apps + }; + const engineChain = await loadPluginChain(policySources ?? [], { jsonOnly: isConfigSourced }); + const engineReport = await executePlugins(engineChain.plugins, policyCtx, engineChain.options); + engine = { + signals: engineReport.signals, + recommendations: engineReport.recommendations, + policyWarnings: engineReport.policyWarnings, + score: engineReport.score, + grade: engineReport.grade + }; + } + + return { + repoPath, + generatedAt: new Date().toISOString(), + isMonorepo: analysis.isMonorepo ?? false, + apps: apps.map((app) => ({ name: app.name, path: app.path })), + pillars, + levels, + achievedLevel, + criteria: criteriaResults, + extras, + areaReports, + policies: policyInfo, + engine + }; +} diff --git a/packages/core/src/services/readiness/scoring.ts b/packages/core/src/services/readiness/scoring.ts new file mode 100644 index 0000000..3dd3bd6 --- /dev/null +++ b/packages/core/src/services/readiness/scoring.ts @@ -0,0 +1,111 @@ +import { PILLAR_GROUP_NAMES, PILLAR_GROUPS } from "./types"; +import type { + ReadinessCriterionResult, + ReadinessPillar, + ReadinessPillarSummary, + ReadinessLevelSummary +} from "./types"; +import type { PillarGroup } from "./types"; + +export function groupPillars( + pillars: ReadinessPillarSummary[] +): Array<{ group: PillarGroup; label: string; pillars: ReadinessPillarSummary[] }> { + const groups: PillarGroup[] = ["repo-health", "ai-setup"]; + return groups.map((group) => ({ + group, + label: PILLAR_GROUP_NAMES[group], + pillars: pillars.filter((p) => PILLAR_GROUPS[p.id] === group) + })); +} + +export function getLevelName(level: number): string { + const names: Record = { + 1: "Functional", + 2: "Documented", + 3: "Standardized", + 4: "Optimized", + 5: "Autonomous" + }; + return names[level] || "Unknown"; +} + +export function getLevelDescription(level: number): string { + const descriptions: Record = { + 1: "Repo builds, tests run, and basic tooling (linter, lockfile) is in place. AI agents can clone and get started.", + 2: "README, CONTRIBUTING guide, and custom AI instructions exist. Agents understand project context and conventions.", + 3: "CI/CD, security policies, CODEOWNERS, and observability are configured. Agents operate within well-defined guardrails.", + 4: "MCP servers, custom agents, and AI skills are set up. Agents have deep integration with project-specific tools and workflows.", + 5: "Full AI-native development: agents can independently plan, implement, test, and ship changes with minimal human oversight." + }; + return descriptions[level] || ""; +} + +export function summarizePillars(criteria: ReadinessCriterionResult[]): ReadinessPillarSummary[] { + const pillarNames: Record = { + "style-validation": "Style & Validation", + "build-system": "Build System", + testing: "Testing", + documentation: "Documentation", + "dev-environment": "Dev Environment", + "code-quality": "Code Quality", + observability: "Observability", + "security-governance": "Security & Governance", + "ai-tooling": "AI Tooling" + }; + + return (Object.keys(pillarNames) as ReadinessPillar[]).map((pillar) => { + const items = criteria.filter((criterion) => criterion.pillar === pillar); + const { passed, total } = countStatus(items); + return { + id: pillar, + name: pillarNames[pillar], + passed, + total, + passRate: total ? passed / total : 0 + }; + }); +} + +export function summarizeLevels( + criteria: ReadinessCriterionResult[], + passRateThreshold = 0.8 +): ReadinessLevelSummary[] { + const levelNames: Record = { + 1: "Functional", + 2: "Documented", + 3: "Standardized", + 4: "Optimized", + 5: "Autonomous" + }; + + const summaries: ReadinessLevelSummary[] = []; + for (let level = 1; level <= 5; level += 1) { + const items = criteria.filter((criterion) => criterion.level === level); + const { passed, total } = countStatus(items); + const passRate = total ? passed / total : 0; + summaries.push({ + level, + name: levelNames[level], + passed, + total, + passRate, + achieved: false + }); + } + + for (const summary of summaries) { + const allPrior = summaries.filter((candidate) => candidate.level <= summary.level); + const achieved = allPrior.every( + (candidate) => candidate.total > 0 && candidate.passRate >= passRateThreshold + ); + summary.achieved = achieved; + } + + return summaries; +} + +export function countStatus(items: ReadinessCriterionResult[]): { passed: number; total: number } { + const relevant = items.filter((item) => item.status !== "skip"); + const passed = relevant.filter((item) => item.status === "pass").length; + return { passed, total: relevant.length }; +} diff --git a/packages/core/src/services/readiness/types.ts b/packages/core/src/services/readiness/types.ts new file mode 100644 index 0000000..6392c9f --- /dev/null +++ b/packages/core/src/services/readiness/types.ts @@ -0,0 +1,139 @@ +import type { RepoApp, RepoAnalysis, Area } from "../analyzer"; +import type { Recommendation, Signal, PolicyWarning, Grade } from "../policy/types"; + +export type ReadinessPillar = + | "style-validation" + | "build-system" + | "testing" + | "documentation" + | "dev-environment" + | "code-quality" + | "observability" + | "security-governance" + | "ai-tooling"; + +export type PillarGroup = "repo-health" | "ai-setup"; + +export const PILLAR_GROUPS: Record = { + "style-validation": "repo-health", + "build-system": "repo-health", + testing: "repo-health", + documentation: "repo-health", + "dev-environment": "repo-health", + "code-quality": "repo-health", + observability: "repo-health", + "security-governance": "repo-health", + "ai-tooling": "ai-setup" +}; + +export const PILLAR_GROUP_NAMES: Record = { + "repo-health": "Repo Health", + "ai-setup": "AI Setup" +}; + +export type ReadinessScope = "repo" | "app" | "area"; + +export type ReadinessStatus = "pass" | "fail" | "skip"; + +export type ReadinessCriterionResult = { + id: string; + title: string; + pillar: ReadinessPillar; + level: number; + scope: ReadinessScope; + impact: "high" | "medium" | "low"; + effort: "low" | "medium" | "high"; + status: ReadinessStatus; + reason?: string; + evidence?: string[]; + passRate?: number; + appSummary?: { passed: number; total: number }; + appFailures?: string[]; + areaSummary?: { passed: number; total: number }; + areaFailures?: string[]; +}; + +export type ReadinessExtraResult = { + id: string; + title: string; + status: ReadinessStatus; + reason?: string; +}; + +export type ReadinessPillarSummary = { + id: ReadinessPillar; + name: string; + passed: number; + total: number; + passRate: number; +}; + +export type ReadinessLevelSummary = { + level: number; + name: string; + passed: number; + total: number; + passRate: number; + achieved: boolean; +}; + +export type AreaReadinessReport = { + area: Area; + criteria: ReadinessCriterionResult[]; + pillars: ReadinessPillarSummary[]; +}; + +export type ReadinessReport = { + repoPath: string; + generatedAt: string; + isMonorepo: boolean; + apps: Array<{ name: string; path: string }>; + pillars: ReadinessPillarSummary[]; + levels: ReadinessLevelSummary[]; + achievedLevel: number; + criteria: ReadinessCriterionResult[]; + extras: ReadinessExtraResult[]; + areaReports?: AreaReadinessReport[]; + policies?: { chain: string[]; criteriaCount: number }; + /** New plugin engine data (populated when using the unified engine). */ + engine?: { + signals: ReadonlyArray; + recommendations: ReadonlyArray; + policyWarnings: ReadonlyArray; + score: number; + grade: Grade; + }; +}; + +export type ReadinessContext = { + repoPath: string; + analysis: RepoAnalysis; + apps: RepoApp[]; + rootFiles: string[]; + rootPackageJson?: Record; + areaPath?: string; + areaFiles?: string[]; +}; + +export type ReadinessCriterion = { + id: string; + title: string; + pillar: ReadinessPillar; + level: number; + scope: ReadinessScope; + impact: "high" | "medium" | "low"; + effort: "low" | "medium" | "high"; + check: (context: ReadinessContext, app?: RepoApp, area?: Area) => Promise; +}; + +export type CheckResult = { + status: ReadinessStatus; + reason?: string; + evidence?: string[]; +}; + +export type InstructionConsistencyResult = { + unified: boolean; + files: string[]; + similarity?: number; +}; diff --git a/src/services/visualReport.ts b/packages/core/src/services/visualReport.ts similarity index 100% rename from src/services/visualReport.ts rename to packages/core/src/services/visualReport.ts diff --git a/src/utils/fs.ts b/packages/core/src/utils/fs.ts similarity index 100% rename from src/utils/fs.ts rename to packages/core/src/utils/fs.ts diff --git a/src/utils/logger.ts b/packages/core/src/utils/logger.ts similarity index 100% rename from src/utils/logger.ts rename to packages/core/src/utils/logger.ts diff --git a/src/utils/output.ts b/packages/core/src/utils/output.ts similarity index 100% rename from src/utils/output.ts rename to packages/core/src/utils/output.ts diff --git a/src/utils/pr.ts b/packages/core/src/utils/pr.ts similarity index 100% rename from src/utils/pr.ts rename to packages/core/src/utils/pr.ts diff --git a/src/utils/repo.ts b/packages/core/src/utils/repo.ts similarity index 100% rename from src/utils/repo.ts rename to packages/core/src/utils/repo.ts diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..84a9947 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/src/cli.ts b/src/cli.ts index d5b0669..01a8e68 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import { createRequire } from "node:module"; +import { DEFAULT_MODEL, DEFAULT_JUDGE_MODEL } from "@agentrc/core/config"; import { Argument, Command } from "commander"; import { analyzeCommand } from "./commands/analyze"; @@ -12,7 +13,6 @@ import { instructionsCommand } from "./commands/instructions"; import { prCommand } from "./commands/pr"; import { readinessCommand } from "./commands/readiness"; import { tuiCommand } from "./commands/tui"; -import { DEFAULT_MODEL, DEFAULT_JUDGE_MODEL } from "./config"; const _require = createRequire(import.meta.url); export const CLI_VERSION = (_require("../package.json") as { version: string }).version; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index a659a29..a00f9e1 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -1,14 +1,13 @@ import path from "path"; +import type { RepoAnalysis } from "@agentrc/core/services/analyzer"; +import { analyzeRepo } from "@agentrc/core/services/analyzer"; +import { safeWriteFile } from "@agentrc/core/utils/fs"; +import { prettyPrintSummary } from "@agentrc/core/utils/logger"; +import type { CommandResult } from "@agentrc/core/utils/output"; +import { outputResult, outputError, shouldLog } from "@agentrc/core/utils/output"; import chalk from "chalk"; -import type { RepoAnalysis } from "../services/analyzer"; -import { analyzeRepo } from "../services/analyzer"; -import { safeWriteFile } from "../utils/fs"; -import { prettyPrintSummary } from "../utils/logger"; -import type { CommandResult } from "../utils/output"; -import { outputResult, outputError, shouldLog } from "../utils/output"; - type AnalyzeOptions = { json?: boolean; quiet?: boolean; diff --git a/src/commands/batch.tsx b/src/commands/batch.tsx index 6b7f346..5c7002b 100644 --- a/src/commands/batch.tsx +++ b/src/commands/batch.tsx @@ -1,20 +1,29 @@ import readline from "readline"; +import type { AzureDevOpsRepo } from "@agentrc/core/services/azureDevops"; +import { getAzureDevOpsToken, getRepo as getAzureRepo } from "@agentrc/core/services/azureDevops"; +import { + runBatchHeadlessGitHub, + runBatchHeadlessAzure, + sanitizeError +} from "@agentrc/core/services/batch"; +import type { ProcessResult } from "@agentrc/core/services/batch"; +import type { GitHubRepo } from "@agentrc/core/services/github"; +import { getGitHubToken, getRepo as getGitHubRepo } from "@agentrc/core/services/github"; +import { safeWriteFile } from "@agentrc/core/utils/fs"; +import type { CommandResult } from "@agentrc/core/utils/output"; +import { + outputResult, + outputError, + createProgressReporter, + shouldLog +} from "@agentrc/core/utils/output"; +import { GITHUB_REPO_RE, AZURE_REPO_RE } from "@agentrc/core/utils/repo"; import { render } from "ink"; import React from "react"; -import type { AzureDevOpsRepo } from "../services/azureDevops"; -import { getAzureDevOpsToken, getRepo as getAzureRepo } from "../services/azureDevops"; -import { runBatchHeadlessGitHub, runBatchHeadlessAzure, sanitizeError } from "../services/batch"; -import type { ProcessResult } from "../services/batch"; -import type { GitHubRepo } from "../services/github"; -import { getGitHubToken, getRepo as getGitHubRepo } from "../services/github"; import { BatchTui } from "../ui/BatchTui"; import { BatchTuiAzure } from "../ui/BatchTuiAzure"; -import { safeWriteFile } from "../utils/fs"; -import type { CommandResult } from "../utils/output"; -import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; -import { GITHUB_REPO_RE, AZURE_REPO_RE } from "../utils/repo"; type BatchOptions = { output?: string; diff --git a/src/commands/batchReadiness.tsx b/src/commands/batchReadiness.tsx index 6092061..5263396 100644 --- a/src/commands/batchReadiness.tsx +++ b/src/commands/batchReadiness.tsx @@ -1,10 +1,10 @@ +import { getGitHubToken } from "@agentrc/core/services/github"; +import { parsePolicySources } from "@agentrc/core/services/policy"; +import { outputError } from "@agentrc/core/utils/output"; import { render } from "ink"; import React from "react"; -import { getGitHubToken } from "../services/github"; -import { parsePolicySources } from "../services/policy"; import { BatchReadinessTui } from "../ui/BatchReadinessTui"; -import { outputError } from "../utils/output"; type BatchReadinessOptions = { output?: string; diff --git a/src/commands/eval.ts b/src/commands/eval.ts index 8f380f1..8e54041 100644 --- a/src/commands/eval.ts +++ b/src/commands/eval.ts @@ -1,12 +1,17 @@ import path from "path"; -import { DEFAULT_MODEL, DEFAULT_JUDGE_MODEL } from "../config"; -import { listCopilotModels } from "../services/copilot"; -import { generateEvalScaffold } from "../services/evalScaffold"; -import { runEval } from "../services/evaluator"; -import { safeWriteFile } from "../utils/fs"; -import type { CommandResult } from "../utils/output"; -import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; +import { DEFAULT_MODEL, DEFAULT_JUDGE_MODEL } from "@agentrc/core/config"; +import { listCopilotModels } from "@agentrc/core/services/copilot"; +import { generateEvalScaffold } from "@agentrc/core/services/evalScaffold"; +import { runEval } from "@agentrc/core/services/evaluator"; +import { safeWriteFile } from "@agentrc/core/utils/fs"; +import type { CommandResult } from "@agentrc/core/utils/output"; +import { + outputResult, + outputError, + createProgressReporter, + shouldLog +} from "@agentrc/core/utils/output"; type EvalOptions = { repo?: string; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 65b5b50..365ccbb 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -1,10 +1,10 @@ import path from "path"; -import { analyzeRepo } from "../services/analyzer"; -import type { FileAction } from "../services/generator"; -import { generateConfigs } from "../services/generator"; -import type { CommandResult } from "../utils/output"; -import { outputResult, outputError, deriveFileStatus, shouldLog } from "../utils/output"; +import { analyzeRepo } from "@agentrc/core/services/analyzer"; +import type { FileAction } from "@agentrc/core/services/generator"; +import { generateConfigs } from "@agentrc/core/services/generator"; +import type { CommandResult } from "@agentrc/core/utils/output"; +import { outputResult, outputError, deriveFileStatus, shouldLog } from "@agentrc/core/utils/output"; import { instructionsCommand } from "./instructions"; diff --git a/src/commands/init.ts b/src/commands/init.ts index 7620270..7f6e40e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,26 +1,29 @@ import path from "path"; -import { checkbox, select } from "@inquirer/prompts"; - -import { analyzeRepo, detectWorkspaces } from "../services/analyzer"; -import type { AgentrcConfig, AgentrcConfigArea } from "../services/analyzer"; -import type { AzureDevOpsOrg, AzureDevOpsProject, AzureDevOpsRepo } from "../services/azureDevops"; +import { analyzeRepo, detectWorkspaces } from "@agentrc/core/services/analyzer"; +import type { AgentrcConfig, AgentrcConfigArea } from "@agentrc/core/services/analyzer"; +import type { + AzureDevOpsOrg, + AzureDevOpsProject, + AzureDevOpsRepo +} from "@agentrc/core/services/azureDevops"; import { getAzureDevOpsToken, listOrganizations, listProjects, listRepos -} from "../services/azureDevops"; -import type { FileAction } from "../services/generator"; -import { generateConfigs } from "../services/generator"; -import { buildAuthedUrl, cloneRepo, isGitRepo, setRemoteUrl } from "../services/git"; -import type { GitHubRepo } from "../services/github"; -import { getGitHubToken, listAccessibleRepos } from "../services/github"; -import { generateCopilotInstructions } from "../services/instructions"; -import { ensureDir, safeWriteFile, validateCachePath } from "../utils/fs"; -import { prettyPrintSummary } from "../utils/logger"; -import type { CommandResult } from "../utils/output"; -import { outputResult, outputError, deriveFileStatus, shouldLog } from "../utils/output"; +} from "@agentrc/core/services/azureDevops"; +import type { FileAction } from "@agentrc/core/services/generator"; +import { generateConfigs } from "@agentrc/core/services/generator"; +import { buildAuthedUrl, cloneRepo, isGitRepo, setRemoteUrl } from "@agentrc/core/services/git"; +import type { GitHubRepo } from "@agentrc/core/services/github"; +import { getGitHubToken, listAccessibleRepos } from "@agentrc/core/services/github"; +import { generateCopilotInstructions } from "@agentrc/core/services/instructions"; +import { ensureDir, safeWriteFile, validateCachePath } from "@agentrc/core/utils/fs"; +import { prettyPrintSummary } from "@agentrc/core/utils/logger"; +import type { CommandResult } from "@agentrc/core/utils/output"; +import { outputResult, outputError, deriveFileStatus, shouldLog } from "@agentrc/core/utils/output"; +import { checkbox, select } from "@inquirer/prompts"; type InitOptions = { github?: boolean; diff --git a/src/commands/instructions.ts b/src/commands/instructions.ts index 004ed1e..895d73c 100644 --- a/src/commands/instructions.ts +++ b/src/commands/instructions.ts @@ -1,7 +1,7 @@ import path from "path"; -import { analyzeRepo, loadAgentrcConfig } from "../services/analyzer"; -import type { InstructionStrategy } from "../services/instructions"; +import { analyzeRepo, loadAgentrcConfig } from "@agentrc/core/services/analyzer"; +import type { InstructionStrategy } from "@agentrc/core/services/instructions"; import { generateCopilotInstructions, generateAreaInstructions, @@ -9,10 +9,15 @@ import { generateNestedAreaInstructions, writeAreaInstruction, writeNestedInstructions -} from "../services/instructions"; -import { ensureDir, safeWriteFile } from "../utils/fs"; -import type { CommandResult } from "../utils/output"; -import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; +} from "@agentrc/core/services/instructions"; +import { ensureDir, safeWriteFile } from "@agentrc/core/utils/fs"; +import type { CommandResult } from "@agentrc/core/utils/output"; +import { + outputResult, + outputError, + createProgressReporter, + shouldLog +} from "@agentrc/core/utils/output"; function skipReason(action: string): string { if (action === "symlink") return "symlink"; diff --git a/src/commands/pr.ts b/src/commands/pr.ts index fbc9e00..2a6fc82 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -1,13 +1,13 @@ import path from "path"; -import { analyzeRepo } from "../services/analyzer"; +import { analyzeRepo } from "@agentrc/core/services/analyzer"; import { createPullRequest as createAzurePullRequest, getAzureDevOpsToken, getRepo as getAzureRepo -} from "../services/azureDevops"; -import { sanitizeError } from "../services/batch"; -import { generateConfigs } from "../services/generator"; +} from "@agentrc/core/services/azureDevops"; +import { sanitizeError } from "@agentrc/core/services/batch"; +import { generateConfigs } from "@agentrc/core/services/generator"; import { buildAuthedUrl, checkoutBranch, @@ -16,14 +16,19 @@ import { isGitRepo, pushBranch, setRemoteUrl -} from "../services/git"; -import { createPullRequest, getRepo, getGitHubToken } from "../services/github"; -import { generateCopilotInstructions } from "../services/instructions"; -import { ensureDir, safeWriteFile, validateCachePath } from "../utils/fs"; -import type { CommandResult } from "../utils/output"; -import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; -import { buildFullPrBody } from "../utils/pr"; -import { GITHUB_REPO_RE, AZURE_REPO_RE } from "../utils/repo"; +} from "@agentrc/core/services/git"; +import { createPullRequest, getRepo, getGitHubToken } from "@agentrc/core/services/github"; +import { generateCopilotInstructions } from "@agentrc/core/services/instructions"; +import { ensureDir, safeWriteFile, validateCachePath } from "@agentrc/core/utils/fs"; +import type { CommandResult } from "@agentrc/core/utils/output"; +import { + outputResult, + outputError, + createProgressReporter, + shouldLog +} from "@agentrc/core/utils/output"; +import { buildFullPrBody } from "@agentrc/core/utils/pr"; +import { GITHUB_REPO_RE, AZURE_REPO_RE } from "@agentrc/core/utils/repo"; const DEFAULT_PR_BRANCH = "agentrc/add-ai-config"; diff --git a/src/commands/readiness.ts b/src/commands/readiness.ts index b5481ba..f0e6a8d 100644 --- a/src/commands/readiness.ts +++ b/src/commands/readiness.ts @@ -1,19 +1,18 @@ import path from "path"; -import chalk from "chalk"; - -import { parsePolicySources } from "../services/policy"; +import { parsePolicySources } from "@agentrc/core/services/policy"; import type { ReadinessReport, ReadinessCriterionResult, AreaReadinessReport, ReadinessPillarSummary -} from "../services/readiness"; -import { runReadinessReport, groupPillars } from "../services/readiness"; -import { generateVisualReport } from "../services/visualReport"; -import { safeWriteFile } from "../utils/fs"; -import type { CommandResult } from "../utils/output"; -import { outputResult, outputError, shouldLog } from "../utils/output"; +} from "@agentrc/core/services/readiness"; +import { runReadinessReport, groupPillars } from "@agentrc/core/services/readiness"; +import { generateVisualReport } from "@agentrc/core/services/visualReport"; +import { safeWriteFile } from "@agentrc/core/utils/fs"; +import type { CommandResult } from "@agentrc/core/utils/output"; +import { outputResult, outputError, shouldLog } from "@agentrc/core/utils/output"; +import chalk from "chalk"; type ReadinessOptions = { json?: boolean; diff --git a/src/commands/tui.tsx b/src/commands/tui.tsx index 79110ce..b38361b 100644 --- a/src/commands/tui.tsx +++ b/src/commands/tui.tsx @@ -1,10 +1,10 @@ import path from "path"; +import { outputError } from "@agentrc/core/utils/output"; import { render } from "ink"; import React from "react"; import { AgentRCTui } from "../ui/tui"; -import { outputError } from "../utils/output"; type TuiOptions = { repo?: string; diff --git a/src/services/__tests__/analyze-output.test.ts b/src/services/__tests__/analyze-output.test.ts index 5d88490..b5d4e3f 100644 --- a/src/services/__tests__/analyze-output.test.ts +++ b/src/services/__tests__/analyze-output.test.ts @@ -2,10 +2,10 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import type { RepoAnalysis } from "@agentrc/core/services/analyzer"; import { afterEach, describe, expect, it, vi } from "vitest"; import { analyzeCommand, formatAnalysisMarkdown } from "../../commands/analyze"; -import type { RepoAnalysis } from "../analyzer"; describe("formatAnalysisMarkdown", () => { function makeAnalysis(overrides: Partial = {}): RepoAnalysis { diff --git a/src/services/__tests__/analyzer.test.ts b/src/services/__tests__/analyzer.test.ts index 8e08137..0a5f5a1 100644 --- a/src/services/__tests__/analyzer.test.ts +++ b/src/services/__tests__/analyzer.test.ts @@ -2,21 +2,20 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; -import { afterEach, describe, expect, it } from "vitest"; - import { analyzeRepo, loadAgentrcConfig, sanitizeAreaName, detectWorkspaces, type Area -} from "../analyzer"; +} from "@agentrc/core/services/analyzer"; import { buildAreaFrontmatter, buildAreaInstructionContent, areaInstructionPath, writeAreaInstruction -} from "../instructions"; +} from "@agentrc/core/services/instructions"; +import { afterEach, describe, expect, it } from "vitest"; describe("analyzeRepo", () => { const tmpDirs: string[] = []; diff --git a/src/services/__tests__/batch.test.ts b/src/services/__tests__/batch.test.ts index 1f8bb1b..505c596 100644 --- a/src/services/__tests__/batch.test.ts +++ b/src/services/__tests__/batch.test.ts @@ -1,11 +1,10 @@ +import { processBatchReadinessRepo, sanitizeError } from "@agentrc/core/services/batch"; +import * as gitModule from "@agentrc/core/services/git"; +import type { GitHubRepo } from "@agentrc/core/services/github"; +import * as readinessModule from "@agentrc/core/services/readiness"; +import type { ReadinessReport } from "@agentrc/core/services/readiness"; import { describe, expect, it, vi, afterEach } from "vitest"; -import { processBatchReadinessRepo, sanitizeError } from "../batch"; -import * as gitModule from "../git"; -import type { GitHubRepo } from "../github"; -import * as readinessModule from "../readiness"; -import type { ReadinessReport } from "../readiness"; - function makeRepo(overrides: Partial = {}): GitHubRepo { return { name: "my-repo", diff --git a/src/services/__tests__/boundaries.test.ts b/src/services/__tests__/boundaries.test.ts index 23e4a12..9900604 100644 --- a/src/services/__tests__/boundaries.test.ts +++ b/src/services/__tests__/boundaries.test.ts @@ -2,13 +2,12 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import { sanitizeError } from "@agentrc/core/services/batch"; +import { safeWriteFile } from "@agentrc/core/utils/fs"; +import { deriveFileStatus, shouldLog } from "@agentrc/core/utils/output"; +import { GITHUB_REPO_RE, AZURE_REPO_RE } from "@agentrc/core/utils/repo"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { safeWriteFile } from "../../utils/fs"; -import { deriveFileStatus, shouldLog } from "../../utils/output"; -import { GITHUB_REPO_RE, AZURE_REPO_RE } from "../../utils/repo"; -import { sanitizeError } from "../batch"; - // ── sanitizeError ── describe("sanitizeError", () => { diff --git a/src/services/__tests__/cachePath.test.ts b/src/services/__tests__/cachePath.test.ts index 5154882..edccb0c 100644 --- a/src/services/__tests__/cachePath.test.ts +++ b/src/services/__tests__/cachePath.test.ts @@ -1,10 +1,9 @@ import os from "os"; import path from "path"; +import { validateCachePath } from "@agentrc/core/utils/fs"; import { describe, expect, it } from "vitest"; -import { validateCachePath } from "../../utils/fs"; - const cacheRoot = path.join(os.tmpdir(), "agentrc-cache"); describe("validateCachePath", () => { diff --git a/src/services/__tests__/copilotSdk.test.ts b/src/services/__tests__/copilotSdk.test.ts index c26d899..b83efcf 100644 --- a/src/services/__tests__/copilotSdk.test.ts +++ b/src/services/__tests__/copilotSdk.test.ts @@ -1,7 +1,9 @@ +import { + attachDefaultPermissionHandler, + type CopilotSdkModule +} from "@agentrc/core/services/copilotSdk"; import { describe, expect, it, vi } from "vitest"; -import { attachDefaultPermissionHandler, type CopilotSdkModule } from "../copilotSdk"; - function buildMockClient() { const createSessionSpy = vi.fn(async (config: Record) => ({ id: "mock-session", diff --git a/src/services/__tests__/facade.test.ts b/src/services/__tests__/facade.test.ts new file mode 100644 index 0000000..0cd2f3a --- /dev/null +++ b/src/services/__tests__/facade.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +/** + * Facade boundary test — ensures every symbol the extension imports + * from CLI services remains defined with the expected type. + * + * If a refactor moves or renames an export, this test will fail, + * catching the break before the extension build does. + */ +describe("extension facade boundary", () => { + it("exports all analyzer symbols", async () => { + const mod = await import("@agentrc/core/services/analyzer"); + expect(typeof mod.analyzeRepo).toBe("function"); + expect(typeof mod.loadAgentrcConfig).toBe("function"); + expect(typeof mod.detectWorkspaces).toBe("function"); + }); + + it("exports generator symbol", async () => { + const mod = await import("@agentrc/core/services/generator"); + expect(typeof mod.generateConfigs).toBe("function"); + }); + + it("exports all instructions symbols", async () => { + const mod = await import("@agentrc/core/services/instructions"); + expect(typeof mod.generateCopilotInstructions).toBe("function"); + expect(typeof mod.generateAreaInstructions).toBe("function"); + expect(typeof mod.generateNestedInstructions).toBe("function"); + expect(typeof mod.generateNestedAreaInstructions).toBe("function"); + expect(typeof mod.writeAreaInstruction).toBe("function"); + expect(typeof mod.writeNestedInstructions).toBe("function"); + }); + + it("exports evaluator symbol", async () => { + const mod = await import("@agentrc/core/services/evaluator"); + expect(typeof mod.runEval).toBe("function"); + }); + + it("exports evalScaffold symbol", async () => { + const mod = await import("@agentrc/core/services/evalScaffold"); + expect(typeof mod.generateEvalScaffold).toBe("function"); + }); + + it("exports all readiness symbols", async () => { + const mod = await import("@agentrc/core/services/readiness"); + expect(typeof mod.runReadinessReport).toBe("function"); + expect(typeof mod.groupPillars).toBe("function"); + expect(typeof mod.getLevelName).toBe("function"); + expect(typeof mod.getLevelDescription).toBe("function"); + }); + + it("exports visualReport symbol", async () => { + const mod = await import("@agentrc/core/services/visualReport"); + expect(typeof mod.generateVisualReport).toBe("function"); + }); + + it("exports github symbol", async () => { + const mod = await import("@agentrc/core/services/github"); + expect(typeof mod.createPullRequest).toBe("function"); + }); + + it("exports azureDevops symbols", async () => { + const mod = await import("@agentrc/core/services/azureDevops"); + expect(typeof mod.createPullRequest).toBe("function"); + expect(typeof mod.getRepo).toBe("function"); + }); + + it("exports pr utility symbol", async () => { + const mod = await import("@agentrc/core/utils/pr"); + expect(typeof mod.isAgentrcFile).toBe("function"); + }); + + it("exports fs utility symbol", async () => { + const mod = await import("@agentrc/core/utils/fs"); + expect(typeof mod.safeWriteFile).toBe("function"); + }); + + it("exports config symbol", async () => { + const mod = await import("@agentrc/core/config"); + expect(typeof mod.DEFAULT_MODEL).toBe("string"); + expect(mod.DEFAULT_MODEL.length).toBeGreaterThan(0); + }); +}); diff --git a/src/services/__tests__/fs.test.ts b/src/services/__tests__/fs.test.ts index 6d3f628..9f33274 100644 --- a/src/services/__tests__/fs.test.ts +++ b/src/services/__tests__/fs.test.ts @@ -3,10 +3,9 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import { ensureDir, safeWriteFile } from "@agentrc/core/utils/fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ensureDir, safeWriteFile } from "../../utils/fs"; - describe("ensureDir", () => { let tmpDir: string; diff --git a/src/services/__tests__/generator.test.ts b/src/services/__tests__/generator.test.ts index 0d201f5..2d988b4 100644 --- a/src/services/__tests__/generator.test.ts +++ b/src/services/__tests__/generator.test.ts @@ -2,11 +2,10 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import type { RepoAnalysis } from "@agentrc/core/services/analyzer"; +import { generateConfigs } from "@agentrc/core/services/generator"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { RepoAnalysis } from "../analyzer"; -import { generateConfigs } from "../generator"; - describe("generateConfigs", () => { let tmpDir: string; diff --git a/src/services/__tests__/git.test.ts b/src/services/__tests__/git.test.ts index 51adfdc..eae0955 100644 --- a/src/services/__tests__/git.test.ts +++ b/src/services/__tests__/git.test.ts @@ -1,7 +1,6 @@ +import { buildAuthedUrl } from "@agentrc/core/services/git"; import { describe, expect, it } from "vitest"; -import { buildAuthedUrl } from "../git"; - describe("buildAuthedUrl", () => { it("adds github x-access-token to https URL", () => { expect(buildAuthedUrl("https://github.com/owner/repo", "tok123", "github")).toBe( diff --git a/src/services/__tests__/instructions-consistency.test.ts b/src/services/__tests__/instructions-consistency.test.ts index 108c481..2498918 100644 --- a/src/services/__tests__/instructions-consistency.test.ts +++ b/src/services/__tests__/instructions-consistency.test.ts @@ -2,10 +2,9 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import { checkInstructionConsistency, contentSimilarity } from "@agentrc/core/services/readiness"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { checkInstructionConsistency, contentSimilarity } from "../readiness"; - describe("contentSimilarity", () => { it("returns 1.0 for identical content", () => { const text = "# Instructions\n\nUse TypeScript strict mode.\n"; diff --git a/src/services/__tests__/instructions.test.ts b/src/services/__tests__/instructions.test.ts index 42f7e8c..42e9e21 100644 --- a/src/services/__tests__/instructions.test.ts +++ b/src/services/__tests__/instructions.test.ts @@ -2,9 +2,7 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import type { Area } from "../analyzer"; +import type { Area } from "@agentrc/core/services/analyzer"; import { writeAreaInstruction, writeInstructionFile, @@ -15,8 +13,9 @@ import { detectExistingInstructions, buildExistingInstructionsSection, parseTopicsFromHub -} from "../instructions"; -import type { NestedInstructionsResult } from "../instructions"; +} from "@agentrc/core/services/instructions"; +import type { NestedInstructionsResult } from "@agentrc/core/services/instructions"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; describe("writeAreaInstruction", () => { let tmpDir: string; diff --git a/src/services/__tests__/output.test.ts b/src/services/__tests__/output.test.ts index 8bd17ff..464eca2 100644 --- a/src/services/__tests__/output.test.ts +++ b/src/services/__tests__/output.test.ts @@ -1,11 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - import { outputResult, outputError, createProgressReporter, type CommandResult -} from "../../utils/output"; +} from "@agentrc/core/utils/output"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; describe("outputResult", () => { let stdoutSpy: ReturnType; diff --git a/src/services/__tests__/policy-adapter.test.ts b/src/services/__tests__/policy-adapter.test.ts index 8a1128a..15c74f2 100644 --- a/src/services/__tests__/policy-adapter.test.ts +++ b/src/services/__tests__/policy-adapter.test.ts @@ -1,8 +1,7 @@ +import { engineReportToReadiness } from "@agentrc/core/services/policy/adapter"; +import type { EngineReport, Signal } from "@agentrc/core/services/policy/types"; import { describe, expect, it } from "vitest"; -import { engineReportToReadiness } from "../policy/adapter"; -import type { EngineReport, Signal } from "../policy/types"; - function makeSignal(overrides: Partial = {}): Signal { return { id: "test-signal", diff --git a/src/services/__tests__/policy-compiler.test.ts b/src/services/__tests__/policy-compiler.test.ts index 1f0eee3..d7c036d 100644 --- a/src/services/__tests__/policy-compiler.test.ts +++ b/src/services/__tests__/policy-compiler.test.ts @@ -1,11 +1,10 @@ +import type { PolicyConfig, ExtraDefinition } from "@agentrc/core/services/policy"; +import { compilePolicyConfig } from "@agentrc/core/services/policy/compiler"; +import type { PolicyContext, Signal } from "@agentrc/core/services/policy/types"; +import type { ReadinessCriterion } from "@agentrc/core/services/readiness"; +import { buildCriteria } from "@agentrc/core/services/readiness"; import { describe, expect, it } from "vitest"; -import type { PolicyConfig, ExtraDefinition } from "../policy"; -import { compilePolicyConfig } from "../policy/compiler"; -import type { PolicyContext, Signal } from "../policy/types"; -import type { ReadinessCriterion } from "../readiness"; -import { buildCriteria } from "../readiness"; - // ─── Helpers ─── function makeCtx(): PolicyContext { diff --git a/src/services/__tests__/policy-engine-types.test.ts b/src/services/__tests__/policy-engine-types.test.ts index 2057ed3..1161573 100644 --- a/src/services/__tests__/policy-engine-types.test.ts +++ b/src/services/__tests__/policy-engine-types.test.ts @@ -1,12 +1,16 @@ -import { describe, expect, it } from "vitest"; - -import type { Signal, Recommendation, SignalPatch, RecommendationPatch } from "../policy/types"; +import type { + Signal, + Recommendation, + SignalPatch, + RecommendationPatch +} from "@agentrc/core/services/policy/types"; import { calculateScore, applySignalPatch, applyRecommendationPatch, resolveSupersedes -} from "../policy/types"; +} from "@agentrc/core/services/policy/types"; +import { describe, expect, it } from "vitest"; // ─── Helpers ─── diff --git a/src/services/__tests__/policy-engine.test.ts b/src/services/__tests__/policy-engine.test.ts index 16f62f2..b9800a0 100644 --- a/src/services/__tests__/policy-engine.test.ts +++ b/src/services/__tests__/policy-engine.test.ts @@ -1,6 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; - -import { executePlugins } from "../policy/engine"; +import { executePlugins } from "@agentrc/core/services/policy/engine"; import type { PolicyPlugin, PolicyContext, @@ -8,7 +6,8 @@ import type { Recommendation, Detector, Recommender -} from "../policy/types"; +} from "@agentrc/core/services/policy/types"; +import { describe, expect, it, vi } from "vitest"; // ─── Helpers ─── diff --git a/src/services/__tests__/policy-loader.test.ts b/src/services/__tests__/policy-loader.test.ts index d5942c3..0d418a7 100644 --- a/src/services/__tests__/policy-loader.test.ts +++ b/src/services/__tests__/policy-loader.test.ts @@ -2,13 +2,12 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import { executePlugins } from "@agentrc/core/services/policy/engine"; +import { buildBuiltinPlugin, loadPluginChain } from "@agentrc/core/services/policy/loader"; +import type { PolicyContext } from "@agentrc/core/services/policy/types"; +import { buildExtras } from "@agentrc/core/services/readiness"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { executePlugins } from "../policy/engine"; -import { buildBuiltinPlugin, loadPluginChain } from "../policy/loader"; -import type { PolicyContext } from "../policy/types"; -import { buildExtras } from "../readiness"; - function makeCtx(): PolicyContext { return { repoPath: "/tmp/test", diff --git a/src/services/__tests__/policy-shadow.test.ts b/src/services/__tests__/policy-shadow.test.ts index b8d2930..0a24b62 100644 --- a/src/services/__tests__/policy-shadow.test.ts +++ b/src/services/__tests__/policy-shadow.test.ts @@ -2,12 +2,11 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import { compareShadow, writeShadowLog } from "@agentrc/core/services/policy/shadow"; +import type { EngineReport } from "@agentrc/core/services/policy/types"; +import type { ReadinessReport, ReadinessCriterionResult } from "@agentrc/core/services/readiness"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { compareShadow, writeShadowLog } from "../policy/shadow"; -import type { EngineReport } from "../policy/types"; -import type { ReadinessReport, ReadinessCriterionResult } from "../readiness"; - function makeLegacyReport(criteria: ReadinessCriterionResult[] = []): ReadinessReport { return { repoPath: "/tmp/test", diff --git a/src/services/__tests__/policy.test.ts b/src/services/__tests__/policy.test.ts index cf00dbe..6697eb9 100644 --- a/src/services/__tests__/policy.test.ts +++ b/src/services/__tests__/policy.test.ts @@ -2,12 +2,11 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import type { ExtraDefinition, PolicyConfig } from "@agentrc/core/services/policy"; +import { loadPolicy, resolveChain, parsePolicySources } from "@agentrc/core/services/policy"; +import type { ReadinessCriterion } from "@agentrc/core/services/readiness"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ExtraDefinition, PolicyConfig } from "../policy"; -import { loadPolicy, resolveChain, parsePolicySources } from "../policy"; -import type { ReadinessCriterion } from "../readiness"; - // ─── Helpers ─── function makeCriterion( diff --git a/src/services/__tests__/pr.test.ts b/src/services/__tests__/pr.test.ts index 0490cf3..62634f0 100644 --- a/src/services/__tests__/pr.test.ts +++ b/src/services/__tests__/pr.test.ts @@ -1,11 +1,10 @@ -import { describe, expect, it } from "vitest"; - import { buildInstructionsPrBody, buildFullPrBody, isAgentrcFile, AGENTRC_FILE_PATTERNS -} from "../../utils/pr"; +} from "@agentrc/core/utils/pr"; +import { describe, expect, it } from "vitest"; describe("buildInstructionsPrBody", () => { it("includes instructions file", () => { diff --git a/src/services/__tests__/readiness-baseline.test.ts b/src/services/__tests__/readiness-baseline.test.ts index 13c132f..abee593 100644 --- a/src/services/__tests__/readiness-baseline.test.ts +++ b/src/services/__tests__/readiness-baseline.test.ts @@ -12,10 +12,14 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import { + runReadinessReport, + buildCriteria, + buildExtras, + groupPillars +} from "@agentrc/core/services/readiness"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { runReadinessReport, buildCriteria, buildExtras, groupPillars } from "../readiness"; - describe("ReadinessReport shape baseline", () => { let repoPath: string; diff --git a/src/services/__tests__/readiness-markdown.test.ts b/src/services/__tests__/readiness-markdown.test.ts index 468850c..45584d7 100644 --- a/src/services/__tests__/readiness-markdown.test.ts +++ b/src/services/__tests__/readiness-markdown.test.ts @@ -1,7 +1,7 @@ +import type { ReadinessReport } from "@agentrc/core/services/readiness"; import { describe, expect, it } from "vitest"; import { formatReadinessMarkdown } from "../../commands/readiness"; -import type { ReadinessReport } from "../readiness"; describe("formatReadinessMarkdown", () => { function makeReport(overrides: Partial = {}): ReadinessReport { diff --git a/src/services/__tests__/readiness.test.ts b/src/services/__tests__/readiness.test.ts index 05a8cc0..2925c49 100644 --- a/src/services/__tests__/readiness.test.ts +++ b/src/services/__tests__/readiness.test.ts @@ -2,10 +2,9 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import { runReadinessReport } from "@agentrc/core/services/readiness"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { runReadinessReport } from "../readiness"; - describe("runReadinessReport", () => { let repoPath: string; diff --git a/src/services/__tests__/visualReport.test.ts b/src/services/__tests__/visualReport.test.ts index 8c54e6b..6252936 100644 --- a/src/services/__tests__/visualReport.test.ts +++ b/src/services/__tests__/visualReport.test.ts @@ -1,8 +1,7 @@ +import type { ReadinessReport } from "@agentrc/core/services/readiness"; +import { generateVisualReport } from "@agentrc/core/services/visualReport"; import { describe, expect, it } from "vitest"; -import type { ReadinessReport } from "../readiness"; -import { generateVisualReport } from "../visualReport"; - function makeReport(overrides: Partial = {}): ReadinessReport { return { repoPath: "/tmp/test-repo", diff --git a/src/ui/BatchReadinessTui.tsx b/src/ui/BatchReadinessTui.tsx index 6b0110c..d9b8a03 100644 --- a/src/ui/BatchReadinessTui.tsx +++ b/src/ui/BatchReadinessTui.tsx @@ -2,16 +2,15 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; +import type { ReadinessProcessResult } from "@agentrc/core/services/batch"; +import { processBatchReadinessRepo } from "@agentrc/core/services/batch"; +import type { GitHubOrg, GitHubRepo } from "@agentrc/core/services/github"; +import { listUserOrgs, listOrgRepos, listAccessibleRepos } from "@agentrc/core/services/github"; +import { generateVisualReport } from "@agentrc/core/services/visualReport"; +import { safeWriteFile, ensureDir, validateCachePath } from "@agentrc/core/utils/fs"; import { Box, Text, useApp, useInput, useIsScreenReaderEnabled } from "ink"; import React, { useEffect, useState } from "react"; -import type { ReadinessProcessResult } from "../services/batch"; -import { processBatchReadinessRepo } from "../services/batch"; -import type { GitHubOrg, GitHubRepo } from "../services/github"; -import { listUserOrgs, listOrgRepos, listAccessibleRepos } from "../services/github"; -import { generateVisualReport } from "../services/visualReport"; -import { safeWriteFile, ensureDir, validateCachePath } from "../utils/fs"; - import { StaticBanner } from "./AnimatedBanner"; type Props = { diff --git a/src/ui/BatchTui.tsx b/src/ui/BatchTui.tsx index d0d6a88..7aa98e5 100644 --- a/src/ui/BatchTui.tsx +++ b/src/ui/BatchTui.tsx @@ -1,16 +1,15 @@ -import { Box, Text, useApp, useInput, useIsScreenReaderEnabled } from "ink"; -import React, { useEffect, useState } from "react"; - -import { processGitHubRepo } from "../services/batch"; -import type { ProcessResult } from "../services/batch"; -import type { GitHubOrg, GitHubRepo } from "../services/github"; +import { processGitHubRepo } from "@agentrc/core/services/batch"; +import type { ProcessResult } from "@agentrc/core/services/batch"; +import type { GitHubOrg, GitHubRepo } from "@agentrc/core/services/github"; import { listUserOrgs, listOrgRepos, listAccessibleRepos, checkReposForInstructions -} from "../services/github"; -import { safeWriteFile } from "../utils/fs"; +} from "@agentrc/core/services/github"; +import { safeWriteFile } from "@agentrc/core/utils/fs"; +import { Box, Text, useApp, useInput, useIsScreenReaderEnabled } from "ink"; +import React, { useEffect, useState } from "react"; import { StaticBanner } from "./AnimatedBanner"; diff --git a/src/ui/BatchTuiAzure.tsx b/src/ui/BatchTuiAzure.tsx index 4ca0869..ddb53f3 100644 --- a/src/ui/BatchTuiAzure.tsx +++ b/src/ui/BatchTuiAzure.tsx @@ -1,16 +1,19 @@ -import { Box, Text, useApp, useInput, useIsScreenReaderEnabled } from "ink"; -import React, { useEffect, useState } from "react"; - -import type { AzureDevOpsOrg, AzureDevOpsProject, AzureDevOpsRepo } from "../services/azureDevops"; +import type { + AzureDevOpsOrg, + AzureDevOpsProject, + AzureDevOpsRepo +} from "@agentrc/core/services/azureDevops"; import { listOrganizations, listProjects, listRepos, checkReposForInstructions -} from "../services/azureDevops"; -import { processAzureRepo } from "../services/batch"; -import type { ProcessResult } from "../services/batch"; -import { safeWriteFile } from "../utils/fs"; +} from "@agentrc/core/services/azureDevops"; +import { processAzureRepo } from "@agentrc/core/services/batch"; +import type { ProcessResult } from "@agentrc/core/services/batch"; +import { safeWriteFile } from "@agentrc/core/utils/fs"; +import { Box, Text, useApp, useInput, useIsScreenReaderEnabled } from "ink"; +import React, { useEffect, useState } from "react"; import { StaticBanner } from "./AnimatedBanner"; diff --git a/src/ui/tui.tsx b/src/ui/tui.tsx index 6ba038c..099efea 100644 --- a/src/ui/tui.tsx +++ b/src/ui/tui.tsx @@ -1,26 +1,25 @@ import fs from "fs/promises"; import path from "path"; -import type { Key } from "ink"; -import { Box, Text, useApp, useInput, useStdout, useIsScreenReaderEnabled } from "ink"; -import React, { useEffect, useMemo, useState } from "react"; - -import type { RepoApp, Area } from "../services/analyzer"; -import { analyzeRepo } from "../services/analyzer"; -import { getAzureDevOpsToken } from "../services/azureDevops"; -import { listCopilotModels } from "../services/copilot"; -import { generateEvalScaffold } from "../services/evalScaffold"; -import type { EvalConfig } from "../services/evalScaffold"; -import { runEval, type EvalResult } from "../services/evaluator"; -import { getGitHubToken } from "../services/github"; +import type { RepoApp, Area } from "@agentrc/core/services/analyzer"; +import { analyzeRepo } from "@agentrc/core/services/analyzer"; +import { getAzureDevOpsToken } from "@agentrc/core/services/azureDevops"; +import { listCopilotModels } from "@agentrc/core/services/copilot"; +import { generateEvalScaffold } from "@agentrc/core/services/evalScaffold"; +import type { EvalConfig } from "@agentrc/core/services/evalScaffold"; +import { runEval, type EvalResult } from "@agentrc/core/services/evaluator"; +import { getGitHubToken } from "@agentrc/core/services/github"; import { generateCopilotInstructions, generateAreaInstructions, buildAreaInstructionContent, areaInstructionPath, writeAreaInstruction -} from "../services/instructions"; -import { safeWriteFile, buildTimestampedName } from "../utils/fs"; +} from "@agentrc/core/services/instructions"; +import { safeWriteFile, buildTimestampedName } from "@agentrc/core/utils/fs"; +import { Box, Text, useApp, useInput, useStdout, useIsScreenReaderEnabled } from "ink"; +import type { Key } from "ink"; +import React, { useEffect, useMemo, useState } from "react"; import { AnimatedBanner, StaticBanner } from "./AnimatedBanner"; import { BatchTui } from "./BatchTui"; diff --git a/tsconfig.json b/tsconfig.json index 308c100..45a23a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,11 @@ "outDir": "dist", "declaration": true, "resolveJsonModule": true, - "types": ["node", "react"] + "types": ["node", "react"], + "paths": { + "@agentrc/core": ["./packages/core/src/index.ts"], + "@agentrc/core/*": ["./packages/core/src/*"] + } }, - "include": ["src"] + "include": ["src", "packages/core/src"] } diff --git a/tsup.config.ts b/tsup.config.ts index 9937cc3..5ac6ca5 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -14,7 +14,13 @@ export default defineConfig({ }, // Keep node_modules as external — they'll be installed via npm external: [/^[^./]/], + // Bundle the workspace package inline (source .ts files, not published) + noExternal: [/@agentrc\/core/], esbuildOptions(options) { options.jsx = "automatic"; + // Resolve @agentrc/core subpath imports to source files + options.alias = { + "@agentrc/core": "./packages/core/src" + }; } }); diff --git a/vitest.config.ts b/vitest.config.ts index fa5e120..9587a1a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ +import path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@agentrc/core": path.resolve(__dirname, "packages/core/src") + } + }, test: { environment: "node", testTimeout: 10_000, diff --git a/vscode-extension/esbuild.mjs b/vscode-extension/esbuild.mjs index 3a8d09f..377f20a 100644 --- a/vscode-extension/esbuild.mjs +++ b/vscode-extension/esbuild.mjs @@ -10,18 +10,28 @@ const watch = process.argv.includes("--watch"); * import.meta with {}, making .resolve undefined and crashing at runtime. * AgentRC always passes an explicit cliPath so this function is dead code, but * the SDK constructor still evaluates it as a default value. + * + * Validated against @github/copilot-sdk ^0.1.24–0.1.29. + * If the SDK changes getBundledCliPath internals the build will fail with + * a clear error message below. */ +const SDK_SHIM_TARGET = + 'const sdkUrl = import.meta.resolve("@github/copilot/sdk");\n const sdkPath = fileURLToPath(sdkUrl);\n return join(dirname(dirname(sdkPath)), "index.js");'; + const shimSdkImportMeta = { name: "shim-sdk-import-meta", setup(build) { build.onLoad({ filter: /copilot-sdk[\\/]dist[\\/]client\.js$/ }, async (args) => { let contents = await readFile(args.path, "utf8"); - // Replace the body of getBundledCliPath with a safe no-op return. - // The function signature and surrounding code stay intact. - contents = contents.replace( - 'const sdkUrl = import.meta.resolve("@github/copilot/sdk");\n const sdkPath = fileURLToPath(sdkUrl);\n return join(dirname(dirname(sdkPath)), "index.js");', - 'return "bundled-cli-unavailable";' - ); + if (!contents.includes(SDK_SHIM_TARGET)) { + throw new Error( + "[shim-sdk-import-meta] SDK internals changed — getBundledCliPath() " + + "target string not found in " + + args.path + + ". Update the shim to match the new SDK version." + ); + } + contents = contents.replace(SDK_SHIM_TARGET, 'return "bundled-cli-unavailable";'); return { contents, loader: "js" }; }); } @@ -41,8 +51,8 @@ const buildOptions = { minify: production, plugins: [shimSdkImportMeta], alias: { - // Resolve AgentRC source imports via the parent src/ directory - agentrc: "../src" + // Resolve @agentrc/core imports via the packages/core/src directory + "@agentrc/core": "../packages/core/src" } }; diff --git a/vscode-extension/src/progress.ts b/vscode-extension/src/progress.ts index 20be10e..5e28830 100644 --- a/vscode-extension/src/progress.ts +++ b/vscode-extension/src/progress.ts @@ -1,5 +1,5 @@ import type * as vscode from "vscode"; -import type { ProgressReporter } from "agentrc/utils/output.js"; +import type { ProgressReporter } from "@agentrc/core/utils/output"; /** * Adapts VS Code's `Progress<{ message, increment }>` to AgentRC's `ProgressReporter` interface. diff --git a/vscode-extension/src/services.ts b/vscode-extension/src/services.ts index 646b7fa..e834b30 100644 --- a/vscode-extension/src/services.ts +++ b/vscode-extension/src/services.ts @@ -1,5 +1,5 @@ -export { analyzeRepo, loadAgentrcConfig, detectWorkspaces } from "agentrc/services/analyzer.js"; -export { generateConfigs } from "agentrc/services/generator.js"; +export { analyzeRepo, loadAgentrcConfig, detectWorkspaces } from "@agentrc/core/services/analyzer"; +export { generateConfigs } from "@agentrc/core/services/generator"; export { generateCopilotInstructions, generateAreaInstructions, @@ -7,21 +7,21 @@ export { generateNestedAreaInstructions, writeAreaInstruction, writeNestedInstructions -} from "agentrc/services/instructions.js"; -export { runEval } from "agentrc/services/evaluator.js"; -export { generateEvalScaffold } from "agentrc/services/evalScaffold.js"; +} from "@agentrc/core/services/instructions"; +export { runEval } from "@agentrc/core/services/evaluator"; +export { generateEvalScaffold } from "@agentrc/core/services/evalScaffold"; export { runReadinessReport, groupPillars, getLevelName, getLevelDescription -} from "agentrc/services/readiness.js"; -export { generateVisualReport } from "agentrc/services/visualReport.js"; -export { createPullRequest } from "agentrc/services/github.js"; +} from "@agentrc/core/services/readiness"; +export { generateVisualReport } from "@agentrc/core/services/visualReport"; +export { createPullRequest } from "@agentrc/core/services/github"; export { createPullRequest as createAzurePullRequest, getRepo as getAzureDevOpsRepo -} from "agentrc/services/azureDevops.js"; -export { isAgentrcFile } from "agentrc/utils/pr.js"; -export { safeWriteFile } from "agentrc/utils/fs.js"; -export { DEFAULT_MODEL } from "agentrc/config.js"; +} from "@agentrc/core/services/azureDevops"; +export { isAgentrcFile } from "@agentrc/core/utils/pr"; +export { safeWriteFile } from "@agentrc/core/utils/fs"; +export { DEFAULT_MODEL } from "@agentrc/core/config"; diff --git a/vscode-extension/src/types.ts b/vscode-extension/src/types.ts index 13fb230..36403c8 100644 --- a/vscode-extension/src/types.ts +++ b/vscode-extension/src/types.ts @@ -6,11 +6,11 @@ export type { AgentrcConfig, AgentrcConfigWorkspace, AgentrcConfigArea -} from "agentrc/services/analyzer.js"; +} from "@agentrc/core/services/analyzer"; export type { ReadinessReport, ReadinessPillarSummary, ReadinessCriterionResult, ReadinessLevelSummary -} from "agentrc/services/readiness.js"; +} from "@agentrc/core/services/readiness"; diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json index dc21d64..4e6e409 100644 --- a/vscode-extension/tsconfig.json +++ b/vscode-extension/tsconfig.json @@ -9,9 +9,9 @@ "skipLibCheck": true, "noEmit": true, "resolveJsonModule": true, - "rootDir": "..", "paths": { - "agentrc/*": ["../src/*"] + "@agentrc/core": ["../packages/core/src/index.ts"], + "@agentrc/core/*": ["../packages/core/src/*"] } }, "include": ["src"],