UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

186 lines 7.69 kB
/** * Heuristic Project-Type Inference * * Fallback used by `project-status` (and any other `kind: status` aggregator) * when contributor discovery returns zero in-use contributors. Detects what * kind of project this is from cheap local signals — manifest files, file * extensions, basic git presence — so the report still says something useful * for projects that have not opted into AIWG contributors. * * Local-only by design: no network calls, no `npm outdated`, no GitHub API. * The whole inference returns in O(few hundred ms) on a typical repo because * it only checks a small number of well-known paths and runs a handful of * bounded globs. * * @architecture @.aiwg/architecture/decisions/ADR-023-contributor-discovery-convention.md * @issue #941 */ import { existsSync, statSync } from 'fs'; import { glob } from 'glob'; import path from 'path'; /** * Manifest files that strongly indicate a code project, mapped to a label. * Order matters only for the summary string when multiple match. */ const CODE_MANIFESTS = [ { file: 'package.json', label: 'JS/TS' }, { file: 'pyproject.toml', label: 'Python' }, { file: 'requirements.txt', label: 'Python (pip)' }, { file: 'Cargo.toml', label: 'Rust' }, { file: 'go.mod', label: 'Go' }, { file: 'pom.xml', label: 'Java (Maven)' }, { file: 'build.gradle', label: 'JVM (Gradle)' }, { file: 'build.gradle.kts', label: 'JVM (Gradle Kotlin)' }, { file: 'Gemfile', label: 'Ruby' }, { file: 'composer.json', label: 'PHP' }, { file: 'mix.exs', label: 'Elixir' }, ]; /** Common test directory names — presence indicates a code project takes testing seriously. */ const TEST_DIRS = ['test', 'tests', '__tests__', 'spec']; /** Asset file extensions, grouped by kind. Kept tight to avoid false positives on icons in docs. */ const ASSET_EXTENSIONS = { audio: ['mp3', 'flac', 'opus', 'm4a', 'wav', 'aac', 'ogg'], video: ['mp4', 'mkv', 'webm', 'mov', 'avi'], image: ['jpg', 'jpeg', 'png', 'webp', 'heic', 'tiff'], }; /** Soft thresholds to call something "asset-dominant" rather than "has a few images". */ const ASSET_DOMINANCE_MIN = 25; function existsCheap(p) { try { return existsSync(p); } catch { return false; } } /** * Format a Unix-epoch ms as a relative-age string. Cheap, no extra deps. */ function relativeAge(mtimeMs) { const ageMs = Date.now() - mtimeMs; const day = 86_400_000; if (ageMs < day) return 'today'; const days = Math.floor(ageMs / day); if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`; const months = Math.floor(days / 30); if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`; const years = Math.floor(days / 365); return `${years} year${years === 1 ? '' : 's'} ago`; } async function detectCodeDimension(projectRoot) { const matchedManifests = CODE_MANIFESTS.filter(m => existsCheap(path.join(projectRoot, m.file))); if (matchedManifests.length === 0) return null; const details = [ { label: 'Manifests', value: matchedManifests.map(m => `${m.file} (${m.label})`).join(', ') }, ]; const testDirs = TEST_DIRS.filter(d => existsCheap(path.join(projectRoot, d))); details.push({ label: 'Test directories', value: testDirs.length > 0 ? testDirs.join(', ') : 'none', }); const readmePath = path.join(projectRoot, 'README.md'); if (existsCheap(readmePath)) { try { const stat = statSync(readmePath); details.push({ label: 'README', value: `last edited ${relativeAge(stat.mtimeMs)}` }); } catch { details.push({ label: 'README', value: 'present (mtime unavailable)' }); } } else { details.push({ label: 'README', value: 'missing' }); } const gitDir = path.join(projectRoot, '.git'); details.push({ label: 'Git', value: existsCheap(gitDir) ? 'tracked' : 'not a git repo' }); // Confidence is high when manifest + test dir + git all agree. const signals = (matchedManifests.length > 0 ? 1 : 0) + (testDirs.length > 0 ? 1 : 0) + (existsCheap(gitDir) ? 1 : 0); const confidence = signals >= 3 ? 'high' : signals === 2 ? 'medium' : 'low'; const summary = matchedManifests.length === 1 ? `${matchedManifests[0].label} code project` : `code project (${matchedManifests.map(m => m.label).join(' + ')})`; return { kind: 'code', confidence, summary, details }; } async function detectDocsDimension(projectRoot) { // Cap the scan so this stays cheap on huge repos. We only need the order // of magnitude. const mdFiles = await glob('**/*.md', { cwd: projectRoot, nodir: true, ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'target/**'], }); if (mdFiles.length < 5) return null; const hasCodeManifest = CODE_MANIFESTS.some(m => existsCheap(path.join(projectRoot, m.file))); // Docs project = lots of markdown AND no source manifests. With a manifest // it's just a code project that has docs — surface both, but mark this // dimension's confidence as low so the summary doesn't lie. const confidence = hasCodeManifest ? 'low' : mdFiles.length > 50 ? 'high' : 'medium'; const details = [ { label: 'Markdown files', value: String(mdFiles.length) }, ]; // Top-level docs dirs hint at organization. const docsDirs = ['docs', 'doc', 'documentation', 'wiki'].filter(d => existsCheap(path.join(projectRoot, d))); details.push({ label: 'Docs directories', value: docsDirs.length > 0 ? docsDirs.join(', ') : 'none at root', }); return { kind: 'docs', confidence, summary: hasCodeManifest ? `${mdFiles.length} markdown files (code project with docs)` : `${mdFiles.length} markdown files (docs / knowledge project)`, details, }; } async function detectAssetsDimension(projectRoot) { const counts = {}; let total = 0; for (const [kind, exts] of Object.entries(ASSET_EXTENSIONS)) { const pattern = `**/*.{${exts.join(',')}}`; const matches = await glob(pattern, { cwd: projectRoot, nodir: true, ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'], }); counts[kind] = matches.length; total += matches.length; } if (total < ASSET_DOMINANCE_MIN) return null; const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]; return { kind: 'assets', confidence: total > 200 ? 'high' : total > 50 ? 'medium' : 'low', summary: `${total} media file${total === 1 ? '' : 's'} (${dominant[0]} dominant)`, details: [ { label: 'Audio', value: String(counts.audio) }, { label: 'Video', value: String(counts.video) }, { label: 'Image', value: String(counts.image) }, ], }; } /** * Run all heuristic detectors and return a unified report. The caller * decides whether to surface this to the user — typically only when * `discoverContributors()` returns zero in-use contributors. */ export async function inferProjectType(projectRoot) { const [code, docs, assets] = await Promise.all([ detectCodeDimension(projectRoot), detectDocsDimension(projectRoot), detectAssetsDimension(projectRoot), ]); const dimensions = [code, docs, assets].filter((d) => d !== null); return { origin: 'heuristic', dimensions, empty: dimensions.length === 0, }; } //# sourceMappingURL=heuristic.js.map