diff --git a/packages/adf/src/__tests__/content-classifier.test.ts b/packages/adf/src/__tests__/content-classifier.test.ts new file mode 100644 index 0000000..5946173 --- /dev/null +++ b/packages/adf/src/__tests__/content-classifier.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { classifyElement, buildMigrationPlan } from '../content-classifier'; +import type { TriggerMap } from '../content-classifier'; +import type { MarkdownElement, MarkdownSection } from '../markdown-parser'; + +const triggerMap: TriggerMap = { + 'frontend.adf': ['react', 'css', 'ui'], + 'backend.adf': ['api', 'node', 'db'], +}; + +function rule(content: string, strength: 'imperative' | 'advisory' | 'neutral' = 'neutral'): MarkdownElement { + return { type: 'rule', content, strength }; +} + +function prose(content: string): MarkdownElement { + return { type: 'prose', content }; +} + +describe('classifyElement', () => { + describe('heading-based routing (no triggerMap)', () => { + it('routes to frontend.adf when heading mentions UI', () => { + const result = classifyElement(rule('Use PascalCase'), 'UI Components'); + expect(result.targetModule).toBe('frontend.adf'); + }); + + it('routes to backend.adf when heading mentions API', () => { + const result = classifyElement(rule('Validate inputs'), 'API Endpoints'); + expect(result.targetModule).toBe('backend.adf'); + }); + + it('routes to core.adf for generic headings', () => { + const result = classifyElement(rule('Use conventional commits'), 'Conventions'); + expect(result.targetModule).toBe('core.adf'); + }); + }); + + describe('content-based fallback routing (with triggerMap)', () => { + it('routes React content under generic heading to frontend.adf', () => { + const result = classifyElement(rule('React components use PascalCase.tsx'), 'Conventions', triggerMap); + expect(result.targetModule).toBe('frontend.adf'); + }); + + it('routes API content under generic heading to backend.adf', () => { + const result = classifyElement(rule('All API routes require auth middleware'), 'Stack', triggerMap); + expect(result.targetModule).toBe('backend.adf'); + }); + + it('routes DB content under generic heading to backend.adf', () => { + const result = classifyElement(rule('Run DB migrations before deploy'), 'General', triggerMap); + expect(result.targetModule).toBe('backend.adf'); + }); + + it('routes CSS content to frontend.adf', () => { + const result = classifyElement(rule('Use CSS modules for scoped styles'), 'Stack', triggerMap); + expect(result.targetModule).toBe('frontend.adf'); + }); + + it('stays on core.adf when no trigger keyword matches', () => { + const result = classifyElement(rule('Use conventional commits'), 'Conventions', triggerMap); + expect(result.targetModule).toBe('core.adf'); + }); + + it('does not override heading-based routing when heading already matches', () => { + const result = classifyElement(rule('Validate API inputs'), 'UI Components', triggerMap); + expect(result.targetModule).toBe('frontend.adf'); + }); + }); + + describe('STAY patterns', () => { + it('marks WSL-specific content as STAY', () => { + const result = classifyElement(rule('Configure credential.helper for WSL'), 'Environment'); + expect(result.decision).toBe('STAY'); + }); + }); + + describe('classification decisions', () => { + it('classifies imperative rules as load-bearing CONSTRAINTS', () => { + const result = classifyElement(rule('NEVER commit secrets', 'imperative'), 'General', triggerMap); + expect(result.decision).toBe('MIGRATE'); + expect(result.targetSection).toBe('CONSTRAINTS'); + expect(result.weight).toBe('load-bearing'); + }); + + it('classifies advisory rules as ADVISORY', () => { + const result = classifyElement(rule('Prefer TypeScript', 'advisory'), 'General', triggerMap); + expect(result.decision).toBe('MIGRATE'); + expect(result.targetSection).toBe('ADVISORY'); + expect(result.weight).toBe('advisory'); + }); + + it('classifies prose as CONTEXT', () => { + const result = classifyElement(prose('The system architecture uses layers'), 'Overview'); + expect(result.decision).toBe('MIGRATE'); + expect(result.targetSection).toBe('CONTEXT'); + }); + }); +}); + +describe('buildMigrationPlan', () => { + it('routes items using triggerMap when provided', () => { + const sections: MarkdownSection[] = [ + { + heading: 'Conventions', + elements: [ + rule('React components use PascalCase'), + rule('API routes use kebab-case'), + rule('Use conventional commits'), + ], + }, + ]; + + const plan = buildMigrationPlan(sections, undefined, triggerMap); + + const frontendItems = plan.migrateItems.filter(i => i.classification.targetModule === 'frontend.adf'); + const backendItems = plan.migrateItems.filter(i => i.classification.targetModule === 'backend.adf'); + const coreItems = plan.migrateItems.filter(i => i.classification.targetModule === 'core.adf'); + + expect(frontendItems).toHaveLength(1); + expect(frontendItems[0].element.content).toContain('React'); + + expect(backendItems).toHaveLength(1); + expect(backendItems[0].element.content).toContain('API'); + + expect(coreItems).toHaveLength(1); + expect(coreItems[0].element.content).toContain('conventional commits'); + }); + + it('works without triggerMap (backward compatible)', () => { + const sections: MarkdownSection[] = [ + { + heading: 'Conventions', + elements: [rule('React components use PascalCase')], + }, + ]; + + const plan = buildMigrationPlan(sections); + expect(plan.migrateItems[0].classification.targetModule).toBe('core.adf'); + }); +}); diff --git a/packages/adf/src/content-classifier.ts b/packages/adf/src/content-classifier.ts index 6d04928..ab300db 100644 --- a/packages/adf/src/content-classifier.ts +++ b/packages/adf/src/content-classifier.ts @@ -18,6 +18,9 @@ export type AdfTargetSection = 'CONSTRAINTS' | 'CONTEXT' | 'ADVISORY'; export type WeightTag = 'load-bearing' | 'advisory'; +/** Module path → lowercase trigger keywords for content-based routing. */ +export type TriggerMap = Record; + export interface ClassificationResult { decision: RouteDecision; targetSection: AdfTargetSection; @@ -86,6 +89,26 @@ function headingToModule(heading: string): string { return 'core.adf'; } +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Content-based fallback routing. When heading-based routing returns core.adf, + * scan element content against ON_DEMAND trigger keywords from the manifest. + */ +function contentToModule(text: string, triggerMap: TriggerMap): string { + const lower = text.toLowerCase(); + for (const [module, triggers] of Object.entries(triggerMap)) { + for (const trigger of triggers) { + if (new RegExp(`\\b${escapeRegex(trigger)}\\b`, 'i').test(lower)) { + return module; + } + } + } + return 'core.adf'; +} + // ============================================================================ // Element Classification // ============================================================================ @@ -93,9 +116,19 @@ function headingToModule(heading: string): string { /** * Classify a single markdown element into an ADF routing decision. */ -export function classifyElement(element: MarkdownElement, heading: string): ClassificationResult { +export function classifyElement( + element: MarkdownElement, + heading: string, + triggerMap?: TriggerMap, +): ClassificationResult { const text = element.content; - const module = headingToModule(heading); + let module = headingToModule(heading); + + // Content-based fallback: when heading routes to core.adf, check element + // content against ON_DEMAND trigger keywords from the manifest. + if (module === 'core.adf' && triggerMap) { + module = contentToModule(text, triggerMap); + } // Check STAY patterns first if (matchesStayPattern(text)) { @@ -256,7 +289,8 @@ function tokenize(text: string): string[] { */ export function buildMigrationPlan( sections: MarkdownSection[], - existingAdf?: AdfDocument + existingAdf?: AdfDocument, + triggerMap?: TriggerMap, ): MigrationPlan { const items: MigrationItem[] = []; @@ -276,7 +310,7 @@ export function buildMigrationPlan( for (const section of sections) { for (const element of section.elements) { - const classification = classifyElement(element, section.heading); + const classification = classifyElement(element, section.heading, triggerMap); // Dedup against existing ADF if (classification.decision === 'MIGRATE' && existingItems.size > 0) { diff --git a/packages/adf/src/index.ts b/packages/adf/src/index.ts index 53edab9..6baea86 100644 --- a/packages/adf/src/index.ts +++ b/packages/adf/src/index.ts @@ -13,6 +13,7 @@ export type { RouteDecision, AdfTargetSection, WeightTag, + TriggerMap, } from './content-classifier'; export * from './types'; export * from './errors'; diff --git a/packages/cli/src/commands/adf-migrate.ts b/packages/cli/src/commands/adf-migrate.ts index 060e87d..81f726b 100644 --- a/packages/cli/src/commands/adf-migrate.ts +++ b/packages/cli/src/commands/adf-migrate.ts @@ -12,12 +12,13 @@ import { parseAdf, formatAdf, applyPatches, + parseManifest, parseMarkdownSections, classifyElement, isDuplicateItem, buildMigrationPlan, } from '@stackbilt/adf'; -import type { AdfDocument, PatchOperation, MigrationItem } from '@stackbilt/adf'; +import type { AdfDocument, PatchOperation, MigrationItem, TriggerMap } from '@stackbilt/adf'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; @@ -104,7 +105,7 @@ export async function adfMigrateCommand(options: CLIOptions, args: string[]): Pr // Per-source Migration // ============================================================================ -interface SourceMigrationResult { +export interface SourceMigrationResult { source: string; skipped: boolean; skipReason?: string; @@ -120,7 +121,7 @@ interface MigrationAction { detail: string; } -function migrateSource( +export function migrateSource( sourcePath: string, aiDir: string, mergeStrategy: 'append' | 'dedupe' | 'replace', @@ -168,7 +169,25 @@ function migrateSource( } } - const plan = buildMigrationPlan(sections, existingAdf); + // Build trigger map from manifest for content-based module routing + let triggerMap: TriggerMap | undefined; + const manifestPath = path.join(aiDir, 'manifest.adf'); + if (fs.existsSync(manifestPath)) { + try { + const manifestDoc = parseAdf(fs.readFileSync(manifestPath, 'utf-8')); + const manifest = parseManifest(manifestDoc); + triggerMap = {}; + for (const mod of manifest.onDemand) { + if (mod.triggers.length > 0) { + triggerMap[mod.path] = mod.triggers.map(t => t.toLowerCase()); + } + } + } catch { + // Manifest parse failed — proceed without content-based routing + } + } + + const plan = buildMigrationPlan(sections, existingAdf, triggerMap); const actions: MigrationAction[] = []; // Group migrate items by target module and section diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 06d5c77..0d3cb3d 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -36,12 +36,14 @@ import { } from './adf'; import { loadPatterns } from '../config'; import { parseAdf, parseManifest } from '@stackbilt/adf'; +import { migrateSource } from './adf-migrate'; +import type { SourceMigrationResult } from './adf-migrate'; // ============================================================================ // Types // ============================================================================ -type StepName = 'detect' | 'setup' | 'adf-init' | 'install' | 'doctor'; +type StepName = 'detect' | 'setup' | 'adf-init' | 'migrate' | 'install' | 'doctor'; type StepStatus = 'pass' | 'fail' | 'skip'; interface StepResult { @@ -97,7 +99,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro const packageManager = detectResult.packageManager; if (options.format === 'text') { - console.log('[1/5] Detecting stack...'); + console.log('[1/6] Detecting stack...'); console.log(` Stack: ${selectedPreset} (${detection.confidence} confidence)`); console.log(` Monorepo: ${detection.monorepo ? 'yes' : 'no'}${detection.monorepo && detection.signals.hasPnpm ? ' (pnpm workspace)' : ''}`); if (detection.warnings.length > 0) { @@ -116,7 +118,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro if (setupResult.step.status === 'fail') warnings++; if (options.format === 'text') { - console.log('[2/5] Setting up governance...'); + console.log('[2/6] Setting up governance...'); for (const f of (setupResult.step.details.created as string[] || [])) { console.log(` Created ${f}`); } @@ -134,7 +136,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro if (adfResult.step.status === 'fail') warnings++; if (options.format === 'text') { - console.log('[3/5] Initializing ADF context...'); + console.log('[3/6] Initializing ADF context...'); for (const f of (adfResult.step.details.files as string[] || [])) { console.log(` Created ${f}`); } @@ -145,14 +147,36 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro } // ======================================================================== - // Phase 4: Install + // Phase 4: Migrate Agent Configs + // ======================================================================== + const migrateResult = runMigratePhase(options, force); + result.steps.push(migrateResult.step); + if (migrateResult.step.status === 'fail') warnings++; + + if (options.format === 'text') { + console.log('[4/6] Migrating agent configs...'); + if (migrateResult.step.status === 'skip') { + console.log(' Skipped (no migratable files)'); + } else if (migrateResult.step.details.dryRun) { + for (const w of migrateResult.step.warnings) { + console.log(` ${w}`); + } + } else { + const migrated = migrateResult.step.details.migrated as number; + console.log(` Migrated ${migrated} file(s)`); + } + console.log(''); + } + + // ======================================================================== + // Phase 5: Install // ======================================================================== const installResult = runInstallPhase(options, skipInstall); result.steps.push(installResult.step); if (installResult.step.status === 'fail') warnings++; if (options.format === 'text') { - console.log('[4/5] Installing dependencies...'); + console.log('[5/6] Installing dependencies...'); if (skipInstall) { console.log(' Skipped (--skip-install)'); } else { @@ -174,14 +198,14 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro } // ======================================================================== - // Phase 5: Doctor + // Phase 6: Doctor // ======================================================================== const doctorResult = runDoctorPhase(options, skipDoctor); result.steps.push(doctorResult.step); if (doctorResult.step.status === 'fail') warnings++; if (options.format === 'text') { - console.log('[5/5] Running health check...'); + console.log('[6/6] Running health check...'); if (skipDoctor) { console.log(' Skipped (--skip-doctor)'); } else { @@ -511,7 +535,94 @@ function runAdfInitPhase( } // ============================================================================ -// Phase 4: Install +// Phase 4: Migrate Agent Configs +// ============================================================================ + +const AGENT_CONFIG_FILES = [ + 'CLAUDE.md', '.cursorrules', 'agents.md', + 'GEMINI.md', 'copilot-instructions.md', +]; + +function runMigratePhase( + options: CLIOptions, + force: boolean, +): { step: StepResult } { + const warnings: string[] = []; + const aiDir = '.ai'; + + try { + // Find agent config files that aren't already thin pointers + const sources = AGENT_CONFIG_FILES.filter(f => { + const fullPath = path.resolve(f); + if (!fs.existsSync(fullPath)) return false; + const content = fs.readFileSync(fullPath, 'utf-8'); + return !POINTER_MARKERS.some(marker => content.includes(marker)); + }); + + if (sources.length === 0) { + return { + step: { + name: 'migrate', + status: 'skip', + details: { skipped: true, reason: 'No migratable agent config files found' }, + warnings, + }, + }; + } + + if (!force) { + warnings.push(`Found ${sources.length} agent config file(s) with migratable content: ${sources.join(', ')}`); + warnings.push("Run with --yes to auto-migrate, or run 'charter adf migrate' separately"); + return { + step: { + name: 'migrate', + status: 'pass', + details: { dryRun: true, sources, migrated: 0 }, + warnings, + }, + }; + } + + // Auto-migrate with --yes + const results: SourceMigrationResult[] = []; + for (const source of sources) { + const result = migrateSource(source, aiDir, 'dedupe', false, false, options); + results.push(result); + } + + const migrated = results.filter(r => !r.skipped).length; + return { + step: { + name: 'migrate', + status: 'pass', + details: { + sources, + migrated, + results: results.map(r => ({ + source: r.source, + skipped: r.skipped, + itemsMigrated: r.plan?.migrateItems.length ?? 0, + })), + }, + warnings, + }, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Migration failed: ${msg}`); + return { + step: { + name: 'migrate', + status: 'fail', + details: { error: msg }, + warnings, + }, + }; + } +} + +// ============================================================================ +// Phase 5: Install // ============================================================================ function runInstallPhase( @@ -577,7 +688,7 @@ function detectPackageManagerFromLockfiles(): 'pnpm' | 'npm' | 'yarn' { } // ============================================================================ -// Phase 5: Doctor +// Phase 6: Doctor // ============================================================================ function runDoctorPhase(