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..0c9f5ef 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'; @@ -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