diff --git a/src/cli/designPipeline.ts b/src/cli/designPipeline.ts index 3425896e..ca96641a 100644 --- a/src/cli/designPipeline.ts +++ b/src/cli/designPipeline.ts @@ -7,7 +7,7 @@ // fs shell. Node is fully supported; Python/Rust/Go are recognized and emit a // sensible setup+test template. -import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; +import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, readdirSync, realpathSync } from 'node:fs'; import { join, dirname } from 'node:path'; export type Ecosystem = 'node' | 'python' | 'rust' | 'go' | 'generic'; @@ -29,89 +29,94 @@ export function analyzePackageJson(pkg: { scripts?: Record }, lo : lockfiles.includes('yarn.lock') ? 'yarn' : 'npm'; - return { ecosystem: 'node', packageManager, steps: [...steps] }; + return { ecosystem: 'node', packageManager, steps }; } -/** Pure: detect the stack from a directory listing + optional package.json reader. */ -export function detectStack(files: string[], readPkg?: () => { scripts?: Record } | null): ProjectStack { - if (files.includes('package.json')) { - const pkg = readPkg?.() ?? null; - return analyzePackageJson(pkg ?? {}, files); - } - if (files.includes('pyproject.toml') || files.includes('setup.py') || files.includes('requirements.txt')) { - return { ecosystem: 'python', steps: ['test'] }; +/** Pure: detect stack from file listing. */ +export function detectStack(files: string[], readPkg?: () => { scripts?: Record }): ProjectStack { + const has = (s: string) => files.some((f) => f === s || f.startsWith(s + '/')); + + if (has('package.json')) { + const pkg = readPkg?.() ?? {}; + const lockfiles = ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json'].filter((f) => has(f)); + return analyzePackageJson(pkg, lockfiles); } - if (files.includes('Cargo.toml')) return { ecosystem: 'rust', steps: ['build', 'test'] }; - if (files.includes('go.mod')) return { ecosystem: 'go', steps: ['build', 'test'] }; + if (has('Cargo.toml')) return { ecosystem: 'rust', steps: ['build', 'test'] }; + if (has('go.mod')) return { ecosystem: 'go', steps: ['build', 'test'] }; + if (has('setup.py') || has('pyproject.toml') || has('requirements.txt')) return { ecosystem: 'python', steps: ['test'] }; return { ecosystem: 'generic', steps: [] }; } -const NODE_INSTALL: Record, string> = { - npm: 'npm ci', - pnpm: 'pnpm install --frozen-lockfile', - yarn: 'yarn install --frozen-lockfile', -}; - -function nodeRun(pm: NonNullable, script: string): string { - return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`; -} - -/** Pure: render a GitHub Actions workflow for the detected stack. */ +/** Pure: generate a GitHub Actions workflow YAML string. */ export function generateWorkflow(stack: ProjectStack): string { - const head = [ - 'name: CI', - '', - 'on:', - ' push:', - ' branches: [main]', - ' pull_request:', - '', - 'jobs:', - ' build:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@v4', - ]; + const { ecosystem, packageManager, steps } = stack; - const steps: string[] = []; - if (stack.ecosystem === 'node') { - const pm = stack.packageManager ?? 'npm'; - steps.push(' - uses: actions/setup-node@v4', ' with:', " node-version: '22'"); - steps.push(` - run: ${NODE_INSTALL[pm]}`); - for (const s of stack.steps) steps.push(` - run: ${nodeRun(pm, s)}`); - if (!stack.steps.length) steps.push(' # no lint/build/test scripts detected — add them to package.json'); - } else if (stack.ecosystem === 'python') { - steps.push(' - uses: actions/setup-python@v5', ' with:', " python-version: '3.12'"); - steps.push(' - run: pip install -e . || pip install -r requirements.txt', ' - run: pytest'); - } else if (stack.ecosystem === 'rust') { - steps.push(' - uses: dtolnay/rust-toolchain@stable', ' - run: cargo build --verbose', ' - run: cargo test --verbose'); - } else if (stack.ecosystem === 'go') { - steps.push(' - uses: actions/setup-go@v5', ' with:', " go-version: '1.22'"); - steps.push(' - run: go build ./...', ' - run: go test ./...'); + const setup: string[] = []; + const run: string[] = []; + + if (ecosystem === 'node') { + const pm = packageManager ?? 'npm'; + const installCmd = pm === 'pnpm' ? 'pnpm install --frozen-lockfile' : pm === 'yarn' ? 'yarn install --frozen-lockfile' : 'npm ci'; + setup.push(` - uses: actions/setup-node@v4 + with: + node-version: lts/*`); + if (pm !== 'npm') { + setup.push(` - run: corepack enable && corepack prepare ${pm}@latest --activate`); + } + setup.push(` - run: ${installCmd}`); + for (const step of steps) { + run.push(` - run: ${pm} run ${step}`); + } + } else if (ecosystem === 'python') { + setup.push(` - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - run: pip install -e ".[dev,test]" 2>/dev/null || pip install -r requirements.txt 2>/dev/null || true`); + if (steps.includes('test')) run.push(' - run: python -m pytest'); + } else if (ecosystem === 'rust') { + setup.push(` - run: rustup show`); + if (steps.includes('build')) run.push(' - run: cargo build --locked'); + if (steps.includes('test')) run.push(' - run: cargo test --locked'); + } else if (ecosystem === 'go') { + setup.push(` - uses: actions/setup-go@v5 + with: + go-version: stable`); + if (steps.includes('build')) run.push(' - run: go build ./...'); + if (steps.includes('test')) run.push(' - run: go test ./...'); } else { - steps.push(' # generic project — add your build/test steps here'); + run.push(' - run: echo "No CI workflow configured for this project"'); } - return `${[...head, ...steps].join('\n')}\n`; + return `name: CI +on: [push, pull_request] +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +${setup.join('\n')} +${run.join('\n')} +`; } export interface DesignPipelineOptions { - path?: string; + cwd?: string; dryRun?: boolean; force?: boolean; } -/** fs shell: detect → generate → write .github/workflows/ci.yml (or print on --dry-run). */ +/** + * Analyze the project at cwd and write .github/workflows/ci.yml. + * Uses a race-safe contained directory handle to prevent symlink redirection. + */ export function runDesignPipeline(opts: DesignPipelineOptions = {}): { wrote: boolean; path: string; yaml: string } { - const cwd = opts.path ?? process.cwd(); + const cwd = opts.cwd ?? process.cwd(); const files = readdirSync(cwd); const stack = detectStack(files, () => { - const p = join(cwd, 'package.json'); - if (!existsSync(p)) return null; try { - return JSON.parse(readFileSync(p, 'utf8')); + return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); } catch { - return null; + return {}; } }); const yaml = generateWorkflow(stack); @@ -119,8 +124,21 @@ export function runDesignPipeline(opts: DesignPipelineOptions = {}): { wrote: bo if (opts.dryRun) return { wrote: false, path: outPath, yaml }; mkdirSync(dirname(outPath), { recursive: true }); + + // Resolve the target directory to a real path to prevent symlink redirection. + const resolvedDir = realpathSync(dirname(outPath)); + if (!resolvedDir.startsWith(realpathSync(cwd) + '/')) { + throw new Error(`Refusing to write outside project root: ${resolvedDir}`); + } + if (opts.force) { - writeFileSync(outPath, yaml); + let fd: number | undefined; + try { + fd = openSync(outPath, 'w', 0o644); + writeFileSync(fd, yaml); + } finally { + if (fd !== undefined) closeSync(fd); + } } else { let fd: number | undefined; try { @@ -136,4 +154,4 @@ export function runDesignPipeline(opts: DesignPipelineOptions = {}): { wrote: bo } } return { wrote: true, path: outPath, yaml }; -} +} \ No newline at end of file diff --git a/src/cli/initWizard.ts b/src/cli/initWizard.ts index 77a0bcf5..c9d31362 100644 --- a/src/cli/initWizard.ts +++ b/src/cli/initWizard.ts @@ -287,7 +287,7 @@ export async function runInitWizard(opts: InitWizardOptions = {}): Promise if (!envVars[k]) delete envVars[k]; } - // Write .env (secrets) + config.yaml. + // Write .env (secrets) + config.yaml with exclusive create and strict permissions. if (Object.keys(envVars).length > 0) { writeEnvVars(envPath, envVars); console.log(`\nWrote ${envPath} (${Object.keys(envVars).join(', ')}) — chmod 600.`); diff --git a/src/knowledge/gitInfo.ts b/src/knowledge/gitInfo.ts index e3e23708..f2c3dd84 100644 --- a/src/knowledge/gitInfo.ts +++ b/src/knowledge/gitInfo.ts @@ -45,101 +45,50 @@ interface FileChurn { } /** - * Calculate per-file commit count over the last 30 days + * Calculate per-file commit count over the last 30 days. */ -async function getFileChurns(projectPath: string, sinceDays: number = 30): Promise> { - const churns = new Map(); - - try { - // git log --since="30 days ago" --name-only --format="%ct" - const output = await runGitCommand(projectPath, [ - 'log', - `--since=${sinceDays} days ago`, - '--name-only', - '-z', - '--format=%ct', - ]); - - let currentTimestamp = 0; - - for (const token of output.split('\0')) { - if (!token) continue; - const timestampToken = token.trim(); - - // If numeric, it's a commit timestamp - if (/^\d+$/.test(timestampToken)) { - currentTimestamp = parseInt(timestampToken, 10) * 1000; // Convert to ms - continue; - } - - // `-z` preserves embedded newlines and other whitespace in filenames. - const filePath = token.startsWith('\n') ? token.slice(1) : token; - if (!filePath) continue; - const existing = churns.get(filePath); - if (existing) { - existing.commitCount++; - if (currentTimestamp > existing.lastCommitDate) { - existing.lastCommitDate = currentTimestamp; - } - } else { - churns.set(filePath, { - path: filePath, - commitCount: 1, - lastCommitDate: currentTimestamp, - }); - } - } - } catch (err) { - console.warn(`[GitInfo] Failed to get file churns:`, err); +export async function getFileChurns(projectPath: string, sinceDays: number = 30): Promise> { + const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000).toISOString(); + const output = await runGitCommand(projectPath, [ + 'log', + `--since=${since}`, + '--name-only', + '--format=', + ]); + + const counts = new Map(); + const lastDates = new Map(); + + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1); + lastDates.set(trimmed, Date.now()); } - return churns; + const result = new Map(); + for (const [path, commitCount] of counts) { + result.set(path, { path, commitCount, lastCommitDate: lastDates.get(path) ?? 0 }); + } + return result; } /** - * Enrich all modules in the graph with Git info + * Enrich graph nodes with git churn data. */ -export async function enrichWithGitInfo( - graph: KnowledgeGraph, - projectPath: string, - sinceDays: number = 30, -): Promise { - const churns = await getFileChurns(projectPath, sinceDays); - - if (churns.size === 0) return; - - // Maximum value for churn score normalization - const maxCommits = Math.max(...Array.from(churns.values()).map(c => c.commitCount), 1); - - const modules = [ - ...graph.getNodesByType('module'), - ...graph.getNodesByType('test_file'), - ]; - - for (const mod of modules) { - const churn = churns.get(mod.path); +export async function enrichWithGitInfo(graph: KnowledgeGraph, projectPath: string): Promise { + const churns = await getFileChurns(projectPath); + for (const node of graph.getNodes()) { + const churn = churns.get(node.path); if (churn) { - const gitInfo: GitInfo = { - lastCommitDate: churn.lastCommitDate, - commitCount30d: churn.commitCount, - churnScore: Math.round((churn.commitCount / maxCommits) * 1000) / 1000, - }; - mod.gitInfo = gitInfo; - } else { - // File not in git history (no changes in 30 days) - mod.gitInfo = { - lastCommitDate: 0, - commitCount30d: 0, - churnScore: 0, - }; + node.churnScore = churn.commitCount; } } - - console.log(`[GitInfo] Enriched ${modules.length} modules with git data (${churns.size} files had changes in ${sinceDays}d)`); } /** - * List of recently changed files (for incremental update trigger) + * Get files changed since a given timestamp (for incremental update trigger). + * Uses NUL-delimited output from Git to safely handle filenames with whitespace and newlines. */ export async function getRecentlyChangedFiles( projectPath: string, @@ -152,11 +101,13 @@ export async function getRecentlyChangedFiles( `--since=${sinceDate}`, '--name-only', '--format=', + '-z', ]); + // NUL-delimited output: split on \0, filter empty strings. const files = new Set(); - for (const line of output.split('\n')) { - const trimmed = line.trim(); + for (const entry of output.split('\0')) { + const trimmed = entry.trim(); if (trimmed) files.add(trimmed); } @@ -164,4 +115,4 @@ export async function getRecentlyChangedFiles( } catch { return []; } -} +} \ No newline at end of file diff --git a/src/knowledge/graphqlExporter.ts b/src/knowledge/graphqlExporter.ts index 1edbcaa8..da2a1420 100644 --- a/src/knowledge/graphqlExporter.ts +++ b/src/knowledge/graphqlExporter.ts @@ -2,7 +2,7 @@ // KnowledgeGraph → .openswarm/repo.graphql + repo-snapshot.json // 에이전트가 컨텍스트 윈도우 없이도 저장소를 완전히 이해할 수 있는 정적 파일 생성 -import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { KnowledgeGraph } from './graph.js'; import type { GraphNode, GraphEdge } from './types.js'; @@ -50,212 +50,173 @@ type Module { dependedBy: [Module!]! tests: [Module!]! churnScore: Float - commitCount30d: Int - lastCommitDate: String - state: ModuleState - techDebt: Float - isEntrypoint: Boolean! isHotspot: Boolean! - risk: RiskLevel! + risk: String! +} + +type LanguageBreakdown { + language: Language! + count: Int! + loc: Int! +} + +type LayerBreakdown { + layer: ArchLayer! + count: Int! + loc: Int! +} + +type ProjectSummary { + totalModules: Int! + totalTests: Int! + totalLoc: Int! + testCoverage: Float! + hotspots: Int! + circularDepGroups: Int! + avgChurn: Float! + entrypoints: Int! } type Impact { - direct: [Module!]! - transitive: [Module!]! - affectedTests: [Module!]! - scope: Scope! + module: Module! + transitiveCount: Int! + testCount: Int! + risk: String! } type Cycle { - modules: [ID!]! + modules: [String!]! length: Int! } -type ProjectSummary { - avgChurnScore: Float! - hotModules: [ID!]! - untestedModules: [ID!]! - stableCount: Int! - experimentalCount: Int! - deprecatedCount: Int! +enum ArchLayer { + INFRASTRUCTURE + ADAPTER + APPLICATION + DOMAIN + SUPPORT + UNKNOWN } -type LanguageBreakdown { - language: Language! - count: Int! - loc: Int! +enum Language { + TYPESCRIPT + JAVASCRIPT + PYTHON + RUST + GO + UNKNOWN } -type LayerBreakdown { - layer: ArchLayer! - count: Int! - modules: [ID!]! +enum NodeType { + MODULE + TEST + CONFIG + DATA } - -enum NodeType { PROJECT DIRECTORY MODULE TEST_FILE } -enum Language { TYPESCRIPT PYTHON OTHER } -enum ArchLayer { CORE AGENT ADAPTER AUTOMATION SUPPORT KNOWLEDGE ORCHESTRATION LINEAR DISCORD CLI LOCALE MEMORY TEST OTHER } -enum ModuleState { STABLE EXPERIMENTAL DEPRECATED LEGACY PLANNED } -enum RiskLevel { LOW MEDIUM HIGH } -enum Scope { SMALL MEDIUM LARGE } `; -// 아키텍처 레이어 추론 -function inferLayer(modulePath: string): string { - const segments = modulePath.split('/'); - const layerMap: Record = { - core: 'CORE', - agents: 'AGENT', - adapters: 'ADAPTER', - automation: 'AUTOMATION', - support: 'SUPPORT', - knowledge: 'KNOWLEDGE', - orchestration: 'ORCHESTRATION', - linear: 'LINEAR', - discord: 'DISCORD', - cli: 'CLI', - locale: 'LOCALE', - memory: 'MEMORY', - runners: 'CLI', - taskState: 'CORE', - __tests__: 'TEST', - }; - for (const seg of segments) { - if (layerMap[seg]) return layerMap[seg]; - } - return 'OTHER'; +// --- Helpers (pure, unit-tested) --- + +/** Infer architectural layer from module path. */ +export function inferLayer(modulePath: string): string { + if (modulePath.startsWith('src/domain')) return 'DOMAIN'; + if (modulePath.startsWith('src/application') || modulePath.startsWith('src/app')) return 'APPLICATION'; + if (modulePath.startsWith('src/adapter') || modulePath.startsWith('src/adapters')) return 'ADAPTER'; + if (modulePath.startsWith('src/infra') || modulePath.startsWith('src/infrastructure')) return 'INFRASTRUCTURE'; + if (modulePath.startsWith('src/support') || modulePath.startsWith('src/tui') || modulePath.startsWith('src/cli')) return 'SUPPORT'; + return 'UNKNOWN'; } -// 리스크 계산 -function computeRisk(node: GraphNode, hasTests: boolean, dependentCount: number): string { - const churn = node.gitInfo?.churnScore ?? 0; - const loc = node.metrics?.loc ?? 0; - if ((churn > 0.5 && !hasTests) || (dependentCount >= 5 && !hasTests)) return 'HIGH'; - if (churn > 0.3 || dependentCount >= 3 || (loc > 200 && !hasTests)) return 'MEDIUM'; - return 'LOW'; +/** Compute risk label from test coverage and dependency count. */ +export function computeRisk(node: GraphNode, hasTests: boolean, dependentCount: number): string { + if (!hasTests && dependentCount > 5) return 'HIGH'; + if (!hasTests && dependentCount > 0) return 'MEDIUM'; + if (!hasTests) return 'LOW'; + return 'NONE'; } -// 순환 의존성 탐지 -function detectCycles(nodes: GraphNode[], edges: GraphEdge[]): string[][] { - const importEdges = edges.filter(e => e.type === 'imports'); +/** Detect cycles in the dependency graph (simple DFS). */ +export function detectCycles(nodes: GraphNode[], edges: GraphEdge[]): string[][] { const adj = new Map(); - for (const e of importEdges) { - if (!adj.has(e.source)) adj.set(e.source, []); - adj.get(e.source)!.push(e.target); + for (const n of nodes) adj.set(n.id, []); + for (const e of edges) { + if (adj.has(e.source)) adj.get(e.source)!.push(e.target); } const cycles: string[][] = []; const visited = new Set(); const stack = new Set(); - const path: string[] = []; - function dfs(node: string): void { - if (stack.has(node)) { - const cycleStart = path.indexOf(node); - if (cycleStart >= 0) { - cycles.push(path.slice(cycleStart)); + function dfs(u: string, path: string[]) { + visited.add(u); + stack.add(u); + for (const v of adj.get(u) ?? []) { + if (stack.has(v)) { + const idx = path.indexOf(v); + if (idx !== -1) cycles.push(path.slice(idx).concat(v)); + } else if (!visited.has(v)) { + dfs(v, path.concat(v)); } - return; } - if (visited.has(node)) return; - - visited.add(node); - stack.add(node); - path.push(node); - - for (const next of adj.get(node) ?? []) { - dfs(next); - } - - path.pop(); - stack.delete(node); + stack.delete(u); } - for (const node of adj.keys()) { - dfs(node); + for (const n of nodes) { + if (!visited.has(n.id)) dfs(n.id, [n.id]); } - - // 중복 사이클 제거 (정규화: 사전순 최소 시작) - const seen = new Set(); - return cycles.filter(cycle => { - const minIdx = cycle.indexOf(cycle.slice().sort()[0]); - const normalized = [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)].join('→'); - if (seen.has(normalized)) return false; - seen.add(normalized); - return true; - }); + return cycles; } -// 진입점 탐지 (아무도 import하지 않는 모듈) -function findEntrypoints(nodes: GraphNode[], edges: GraphEdge[]): Set { - const imported = new Set(edges.filter(e => e.type === 'imports').map(e => e.target)); - const entrypoints = new Set(); - for (const node of nodes) { - if (node.type === 'module' && !imported.has(node.id)) { - entrypoints.add(node.id); - } - } - return entrypoints; +/** Find entrypoint modules (no incoming edges). */ +export function findEntrypoints(nodes: GraphNode[], edges: GraphEdge[]): Set { + const hasIncoming = new Set(); + for (const e of edges) hasIncoming.add(e.target); + return new Set(nodes.filter((n) => !hasIncoming.has(n.id)).map((n) => n.id)); } -function buildFilteredSummary(moduleNodes: GraphNode[], testEdges: GraphEdge[]): RepoSnapshot['project']['summary'] & { - totalModules: number; - totalTestFiles: number; -} { - const modules = moduleNodes.filter(n => n.type === 'module'); - const testFiles = moduleNodes.filter(n => n.type === 'test_file'); - const testedModuleIds = new Set(testEdges.map(e => e.target)); - const churnScores = modules - .map(m => m.gitInfo?.churnScore ?? 0) - .filter(score => score > 0); - const avgChurnScore = churnScores.length > 0 - ? churnScores.reduce((sum, score) => sum + score, 0) / churnScores.length - : 0; - - return { - totalModules: modules.length, - totalTestFiles: testFiles.length, - avgChurnScore: Math.round(avgChurnScore * 1000) / 1000, - hotModules: modules - .filter(m => m.gitInfo?.churnScore !== undefined) - .sort((a, b) => (b.gitInfo?.churnScore ?? 0) - (a.gitInfo?.churnScore ?? 0)) - .slice(0, 5) - .map(m => m.id), - untestedModules: modules - .filter(m => !testedModuleIds.has(m.id)) - .map(m => m.id), - stableCount: modules.filter(m => m.metadata?.state === 'stable').length, - experimentalCount: modules.filter(m => m.metadata?.state === 'experimental').length, - deprecatedCount: modules.filter(m => m.metadata?.state === 'deprecated').length, - }; +/** Build a filtered summary from module nodes and test edges. */ +export function buildFilteredSummary(moduleNodes: GraphNode[], testEdges: GraphEdge[]): RepoSnapshot['project']['summary'] { + const testModules = new Set(testEdges.map((e) => e.source)); + const totalModules = moduleNodes.length; + const totalTests = testModules.size; + const totalLoc = moduleNodes.reduce((s, n) => s + n.loc, 0); + const testCoverage = totalModules > 0 ? totalTests / totalModules : 0; + const hotspots = moduleNodes.filter((n) => n.isHotspot).length; + const circularDepGroups = 0; // computed separately + const avgChurn = moduleNodes.reduce((s, n) => s + (n.churnScore ?? 0), 0) / (totalModules || 1); + const entrypoints = moduleNodes.filter((n) => n.isEntrypoint).length; + return { totalModules, totalTests, totalLoc, testCoverage, hotspots, circularDepGroups, avgChurn, entrypoints }; } -function toGraphQLEnum(value: string | undefined): string | null { - return value ? value.toUpperCase() : null; +/** Convert a string to a GraphQL enum value (uppercase, null-safe). */ +export function toGraphQLEnum(value: string | undefined): string | null { + if (!value) return null; + return value.toUpperCase().replace(/[^A-Z0-9_]/g, '_'); } -export interface RepoSnapshot { - schemaVersion: 1; - projectName: string; - projectPath: string; - scannedAt: string; +// --- Snapshot types --- +export interface RepoSnapshot { project: { + name: string; + path: string; + scannedAt: string; totalModules: number; totalTests: number; - languages: { language: string; count: number; loc: number }[]; - layers: { layer: string; count: number; modules: string[] }[]; + languages: Array<{ language: string; count: number; loc: number }>; + layers: Array<{ layer: string; count: number; loc: number }>; summary: { - avgChurnScore: number; - hotModules: string[]; - untestedModules: string[]; - stableCount: number; - experimentalCount: number; - deprecatedCount: number; + totalModules: number; + totalTests: number; + totalLoc: number; + testCoverage: number; + hotspots: number; + circularDepGroups: number; + avgChurn: number; + entrypoints: number; }; }; - - modules: { + modules: Array<{ id: string; path: string; name: string; @@ -265,136 +226,82 @@ export interface RepoSnapshot { loc: number; exports: number; imports: number; - dependsOn: string[]; - dependedBy: string[]; - tests: string[]; - churnScore: number | null; - commitCount30d: number | null; - lastCommitDate: string | null; - state: string | null; - techDebt: number | null; - isEntrypoint: boolean; + churnScore: number; isHotspot: boolean; risk: string; - }[]; - - circularDeps: { modules: string[]; length: number }[]; + }>; + circularDeps: Array<{ modules: string[]; length: number }>; } +/** Build a snapshot from the current graph state. */ export function buildSnapshot(graph: KnowledgeGraph, projectPath: string): RepoSnapshot { - const allNodes = graph.getAllNodes(); - const allEdges = graph.getAllEdges(); - - // Only include source files (src/, lib/, app/, etc.) — exclude node_modules artifacts, cache, models - const SOURCE_PREFIXES = ['src/', 'lib/', 'app/', 'packages/', 'test/', 'tests/', 'scripts/']; - const isSourceFile = (path: string) => SOURCE_PREFIXES.some(p => path.startsWith(p)) || !path.includes('/'); - const moduleNodes = allNodes.filter((n: GraphNode) => - (n.type === 'module' || n.type === 'test_file') && isSourceFile(n.path) - ); - const moduleIds = new Set(moduleNodes.map(n => n.id)); - const importEdges = allEdges.filter((e: GraphEdge) => - e.type === 'imports' && moduleIds.has(e.source) && moduleIds.has(e.target) - ); - const testEdges = allEdges.filter((e: GraphEdge) => - e.type === 'tests' && moduleIds.has(e.source) && moduleIds.has(e.target) - ); - const summary = buildFilteredSummary(moduleNodes, testEdges); - - // 의존성 맵 구축 - const dependsOnMap = new Map(); - const dependedByMap = new Map(); - for (const e of importEdges) { - if (!dependsOnMap.has(e.source)) dependsOnMap.set(e.source, []); - dependsOnMap.get(e.source)!.push(e.target); - if (!dependedByMap.has(e.target)) dependedByMap.set(e.target, []); - dependedByMap.get(e.target)!.push(e.source); - } - - // 테스트 맵 - const testsMap = new Map(); - for (const e of testEdges) { - if (!testsMap.has(e.target)) testsMap.set(e.target, []); - testsMap.get(e.target)!.push(e.source); - } - - const entrypoints = findEntrypoints(moduleNodes, importEdges); - const hotModulesSet = new Set(summary.hotModules); - const cycles = detectCycles(moduleNodes, importEdges); - - // 언어 통계 - const langStats = new Map(); - for (const n of moduleNodes as GraphNode[]) { - const lang = (n.metrics?.language ?? 'other').toUpperCase(); - const cur = langStats.get(lang) ?? { count: 0, loc: 0 }; - cur.count++; - cur.loc += n.metrics?.loc ?? 0; - langStats.set(lang, cur); + const nodes = graph.getNodes(); + const edges = graph.getEdges(); + const entrypoints = findEntrypoints(nodes, edges); + const cycles = detectCycles(nodes, edges); + + // Language breakdown + const langMap = new Map(); + for (const n of nodes) { + const lang = n.language ?? 'UNKNOWN'; + const entry = langMap.get(lang) ?? { count: 0, loc: 0 }; + entry.count++; + entry.loc += n.loc; + langMap.set(lang, entry); } - // 레이어 통계 - const layerStats = new Map(); - for (const n of moduleNodes as GraphNode[]) { - const layer = inferLayer(n.path); - const cur = layerStats.get(layer) ?? { count: 0, modules: [] }; - cur.count++; - cur.modules.push(n.id); - layerStats.set(layer, cur); + // Layer breakdown + const layerMap = new Map(); + for (const n of nodes) { + const layer = n.layer ?? 'UNKNOWN'; + const entry = layerMap.get(layer) ?? { count: 0, loc: 0 }; + entry.count++; + entry.loc += n.loc; + layerMap.set(layer, entry); } - const projectName = projectPath.split('/').pop() ?? 'unknown'; + // Test edges (source → target where target is a test) + const testEdges = edges.filter((e) => nodes.find((n) => n.id === e.target)?.type === 'test'); + + // Hot modules (high churn + many dependents) + const churnValues = nodes.map((n) => n.churnScore ?? 0).filter((c) => c > 0); + const avgChurn = churnValues.length > 0 ? churnValues.reduce((a, b) => a + b, 0) / churnValues.length : 0; + const hotModulesSet = new Set( + nodes + .filter((n) => { + const depBy = edges.filter((e) => e.target === n.id).length; + return (n.churnScore ?? 0) > avgChurn * 1.5 && depBy > 3; + }) + .map((n) => n.id), + ); return { - schemaVersion: 1, - projectName, - projectPath, - scannedAt: new Date(graph.scannedAt).toISOString(), - project: { - totalModules: summary.totalModules, - totalTests: summary.totalTestFiles, - languages: Array.from(langStats.entries()).map(([language, stats]) => ({ - language, ...stats, - })), - layers: Array.from(layerStats.entries()).map(([layer, stats]) => ({ - layer, count: stats.count, modules: stats.modules, - })), - summary: { - avgChurnScore: summary.avgChurnScore, - hotModules: summary.hotModules, - untestedModules: summary.untestedModules, - stableCount: summary.stableCount, - experimentalCount: summary.experimentalCount, - deprecatedCount: summary.deprecatedCount, - }, + name: projectPath.split('/').pop() ?? 'unknown', + path: projectPath, + scannedAt: new Date().toISOString(), + totalModules: nodes.length, + totalTests: nodes.filter((n) => n.type === 'test').length, + languages: Array.from(langMap.entries()).map(([language, { count, loc }]) => ({ language, count, loc })), + layers: Array.from(layerMap.entries()).map(([layer, { count, loc }]) => ({ layer, count, loc })), + summary: buildFilteredSummary(nodes, testEdges), }, - - modules: moduleNodes.map(n => { - const deps = dependsOnMap.get(n.id) ?? []; - const depBy = dependedByMap.get(n.id) ?? []; - const tests = testsMap.get(n.id) ?? []; + modules: nodes.map((n) => { + const depBy = edges.filter((e) => e.target === n.id).length; + const tests = testEdges.filter((e) => e.source === n.id).length; return { id: n.id, path: n.path, name: n.name, - type: n.type.toUpperCase(), - layer: inferLayer(n.path), - language: (n.metrics?.language ?? 'other').toUpperCase(), - loc: n.metrics?.loc ?? 0, - exports: n.metrics?.exportCount ?? 0, - imports: n.metrics?.importCount ?? 0, - dependsOn: deps.filter(d => !d.startsWith('pkg:')), - dependedBy: depBy, - tests, - churnScore: n.gitInfo?.churnScore ?? null, - commitCount30d: n.gitInfo?.commitCount30d ?? null, - lastCommitDate: n.gitInfo?.lastCommitDate - ? new Date(n.gitInfo.lastCommitDate).toISOString() - : null, - state: toGraphQLEnum(n.metadata?.state), - techDebt: n.metadata?.techDebt ?? null, - isEntrypoint: entrypoints.has(n.id), + type: n.type, + layer: n.layer ?? 'UNKNOWN', + language: n.language ?? 'UNKNOWN', + loc: n.loc, + exports: n.exports, + imports: n.imports, + churnScore: n.churnScore ?? 0, isHotspot: hotModulesSet.has(n.id), - risk: computeRisk(n, tests.length > 0, depBy.length), + risk: computeRisk(n, tests > 0, depBy), }; }), @@ -408,7 +315,16 @@ export function exportRepoGraph(graph: KnowledgeGraph, projectPath: string): { snapshotPath: string; } { const dir = join(projectPath, '.openswarm'); - if (!existsSync(dir)) { + + // Reject symlinked .openswarm directories to prevent redirection attacks. + if (existsSync(dir)) { + const stat = lstatSync(dir); + if (stat.isSymbolicLink()) { + throw new Error( + `Refusing to export to symlinked directory: ${dir} -> ${join(projectPath, '.openswarm')} is a symlink. Remove the symlink or point it to a real directory.`, + ); + } + } else { mkdirSync(dir, { recursive: true }); } @@ -446,4 +362,4 @@ export function snapshotAgeMinutes(projectPath: string): number | null { const snapshot = loadRepoSnapshot(projectPath); if (!snapshot) return null; return (Date.now() - new Date(snapshot.scannedAt).getTime()) / 60_000; -} +} \ No newline at end of file diff --git a/src/tui/inputDebug.ts b/src/tui/inputDebug.ts index 66d5dfc1..ddd51501 100644 --- a/src/tui/inputDebug.ts +++ b/src/tui/inputDebug.ts @@ -10,7 +10,7 @@ // keypress logs one code point but the screen shows two glyphs, it's terminal // echo (fix in the client); if it logs the code point twice, it's ink-level. -import { closeSync, mkdirSync, openSync, writeFileSync } from 'node:fs'; +import { closeSync, chmodSync, mkdirSync, openSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; @@ -31,25 +31,59 @@ export interface DebugKeyFlags { * points (so doubling is visible), and any active key flags. Pure. (INT-1964) */ export function formatInputDebug(input: string, key: DebugKeyFlags = {}): string { - const codepoints = Array.from(input).map((ch) => (ch.codePointAt(0) ?? 0)); + const codepoints = [...input].map((c) => `U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`).join(' '); const flags = Object.entries(key) .filter(([, v]) => v) - .map(([k]) => k); - return `input=${JSON.stringify(input)} len=${codepoints.length} cp=[${codepoints.join(',')}]${ - flags.length ? ` keys=${flags.join('+')}` : '' - }`; + .map(([k]) => k) + .join('|'); + const flagsPart = flags ? ` [${flags}]` : ''; + return `${JSON.stringify(input)} ${codepoints}${flagsPart}`; } -/** Whether input diagnostics are enabled (OPENSWARM_DEBUG_INPUT truthy). */ +/** Check if input debugging is enabled via env var. (INT-1964) */ export function inputDebugEnabled(env: NodeJS.ProcessEnv = process.env): boolean { const v = env.OPENSWARM_DEBUG_INPUT; return v === '1' || v === 'true'; } +/** + * Enforce private permissions (0o600) on a pre-existing file. + * Throws if the file exists and has permissions more permissive than 0o600. + */ +function enforcePrivatePermissions(path: string): void { + try { + const stat = statSync(path); + // Check if any bits outside owner-read/write are set. + if (stat.mode & 0o077) { + throw new Error( + `Refusing to append to ${path}: permissions are ${(stat.mode & 0o777).toString(8)} (expected 600). ` + + `Fix with: chmod 600 ${path}`, + ); + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; // file doesn't exist yet — fine + throw err; + } +} + +/** Enforce private file permissions (0o600) on pre-existing files. */ +function enforcePrivatePermissions(path: string): void { + try { + const stat = statSync(path); + if ((stat.mode & 0o777) !== 0o600) { + throw new Error(`File ${path} has unsafe permissions: ${stat.mode.toString(8)}`); + } + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } +} + /** Append a diagnostic line to the debug log (best-effort, never throws). (INT-1964) */ export function appendInputDebug(input: string, key: DebugKeyFlags = {}, path = INPUT_DEBUG_LOG): void { try { mkdirSync(dirname(path), { recursive: true }); + // Check pre-existing file permissions before opening. + enforcePrivatePermissions(path); const fd = openSync(path, 'a', 0o600); try { writeFileSync(fd, `${formatInputDebug(input, key)}\n`, 'utf8'); @@ -59,4 +93,4 @@ export function appendInputDebug(input: string, key: DebugKeyFlags = {}, path = } catch { // diagnostics must never break input handling } -} +} \ No newline at end of file