Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions packages/adf/src/__tests__/content-classifier.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
42 changes: 38 additions & 4 deletions packages/adf/src/content-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;

export interface ClassificationResult {
decision: RouteDecision;
targetSection: AdfTargetSection;
Expand Down Expand Up @@ -86,16 +89,46 @@ 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
// ============================================================================

/**
* 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)) {
Expand Down Expand Up @@ -256,7 +289,8 @@ function tokenize(text: string): string[] {
*/
export function buildMigrationPlan(
sections: MarkdownSection[],
existingAdf?: AdfDocument
existingAdf?: AdfDocument,
triggerMap?: TriggerMap,
): MigrationPlan {
const items: MigrationItem[] = [];

Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/adf/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type {
RouteDecision,
AdfTargetSection,
WeightTag,
TriggerMap,
} from './content-classifier';
export * from './types';
export * from './errors';
23 changes: 21 additions & 2 deletions packages/cli/src/commands/adf-migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down