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
59 changes: 59 additions & 0 deletions packages/adf/src/__tests__/classifier-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { classifyElement, buildMigrationPlan } from '../content-classifier';
import type { ClassifierConfig } from '../content-classifier';
import type { MarkdownElement, MarkdownSection } from '../markdown-parser';

function rule(content: string, strength: 'imperative' | 'advisory' | 'neutral' = 'imperative'): MarkdownElement {
return { type: 'rule', content, strength };
}

describe('ClassifierConfig', () => {
describe('stayPatterns', () => {
it('uses default patterns when config omitted', () => {
const result = classifyElement(rule('Use WSL credential.helper'), 'Setup');
expect(result.decision).toBe('STAY');
});

it('overrides stay patterns with custom list', () => {
const config: ClassifierConfig = { stayPatterns: [/\bcustom-env\b/i] };
// WSL no longer triggers STAY with custom patterns
const r1 = classifyElement(rule('Use WSL credential.helper'), 'Setup', undefined, config);
expect(r1.decision).toBe('MIGRATE');
// Custom pattern does trigger STAY
const r2 = classifyElement(rule('Set custom-env variable'), 'Setup', undefined, config);
expect(r2.decision).toBe('STAY');
});
});

describe('headingRoutes', () => {
it('uses default heading routes when config omitted', () => {
const result = classifyElement(rule('Use React hooks'), 'Frontend Design');
expect(result.targetModule).toBe('frontend.adf');
});

it('overrides heading routes with custom list', () => {
const config: ClassifierConfig = {
headingRoutes: [{ pattern: /\binfra\b/, module: 'infra.adf' }],
};
// "Frontend" no longer matches custom routes → core.adf
const r1 = classifyElement(rule('Use React hooks'), 'Frontend Design', undefined, config);
expect(r1.targetModule).toBe('core.adf');
// "Infra" matches custom route
const r2 = classifyElement(rule('Use Terraform'), 'Infra Setup', undefined, config);
expect(r2.targetModule).toBe('infra.adf');
});
});

describe('buildMigrationPlan threading', () => {
it('threads config to classifyElement', () => {
const sections: MarkdownSection[] = [
{ heading: 'Infra', elements: [rule('Provision servers')] },
];
const config: ClassifierConfig = {
headingRoutes: [{ pattern: /\binfra\b/, module: 'infra.adf' }],
};
const plan = buildMigrationPlan(sections, undefined, undefined, config);
expect(plan.migrateItems[0].classification.targetModule).toBe('infra.adf');
});
});
});
35 changes: 35 additions & 0 deletions packages/adf/src/__tests__/strength-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { parseMarkdownSections } from '../markdown-parser';
import type { StrengthConfig } from '../markdown-parser';

describe('StrengthConfig', () => {
const md = '## Rules\n- ALWAYS do X\n- prefer Y\n- do Z';

it('uses default patterns when config omitted', () => {
const sections = parseMarkdownSections(md);
const rules = sections[0].elements;
expect(rules[0].strength).toBe('imperative');
expect(rules[1].strength).toBe('advisory');
expect(rules[2].strength).toBe('neutral');
});

it('respects custom imperativePatterns', () => {
const config: StrengthConfig = { imperativePatterns: [/\bdo Z\b/] };
const sections = parseMarkdownSections(md, config);
const rules = sections[0].elements;
// "ALWAYS" no longer matches custom list → neutral
expect(rules[0].strength).toBe('neutral');
// "do Z" now matches imperative
expect(rules[2].strength).toBe('imperative');
});

it('respects custom advisoryPatterns', () => {
const config: StrengthConfig = { advisoryPatterns: [/\bdo Z\b/] };
const sections = parseMarkdownSections(md, config);
const rules = sections[0].elements;
// "prefer" no longer matches custom list → neutral
expect(rules[1].strength).toBe('neutral');
// "do Z" now matches advisory
expect(rules[2].strength).toBe('advisory');
});
});
28 changes: 21 additions & 7 deletions packages/adf/src/content-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export type WeightTag = 'load-bearing' | 'advisory';
/** Module path → lowercase trigger keywords for content-based routing. */
export type TriggerMap = Record<string, string[]>;

/** Optional overrides for classification heuristics. */
export interface ClassifierConfig {
stayPatterns?: RegExp[];
headingRoutes?: Array<{ pattern: RegExp; module: string }>;
}

export interface ClassificationResult {
decision: RouteDecision;
targetSection: AdfTargetSection;
Expand Down Expand Up @@ -69,23 +75,29 @@ const STAY_PATTERNS: RegExp[] = [
// Classification Helpers
// ============================================================================

function matchesStayPattern(text: string): boolean {
return STAY_PATTERNS.some(p => p.test(text));
function matchesStayPattern(text: string, patterns: RegExp[] = STAY_PATTERNS): boolean {
return patterns.some(p => p.test(text));
}

/**
* Map a heading name to the most appropriate ADF target module.
*/
function headingToModule(heading: string): string {
function headingToModule(heading: string, routes?: ClassifierConfig['headingRoutes']): string {
const lower = heading.toLowerCase();

if (routes) {
for (const route of routes) {
if (route.pattern.test(lower)) return route.module;
}
return 'core.adf';
}

if (/\b(design.system|ui|frontend|css|component|react|vue|svelte)\b/.test(lower)) {
return 'frontend.adf';
}
if (/\b(api|backend|deploy|server|database|db|endpoint)\b/.test(lower)) {
return 'backend.adf';
}
// Default: everything routes to core.adf
return 'core.adf';
}

Expand Down Expand Up @@ -120,9 +132,10 @@ export function classifyElement(
element: MarkdownElement,
heading: string,
triggerMap?: TriggerMap,
config?: ClassifierConfig,
): ClassificationResult {
const text = element.content;
let module = headingToModule(heading);
let module = headingToModule(heading, config?.headingRoutes);

// Content-based fallback: when heading routes to core.adf, check element
// content against ON_DEMAND trigger keywords from the manifest.
Expand All @@ -131,7 +144,7 @@ export function classifyElement(
}

// Check STAY patterns first
if (matchesStayPattern(text)) {
if (matchesStayPattern(text, config?.stayPatterns)) {
return {
decision: 'STAY',
targetSection: 'CONTEXT',
Expand Down Expand Up @@ -291,6 +304,7 @@ export function buildMigrationPlan(
sections: MarkdownSection[],
existingAdf?: AdfDocument,
triggerMap?: TriggerMap,
config?: ClassifierConfig,
): MigrationPlan {
const items: MigrationItem[] = [];

Expand All @@ -310,7 +324,7 @@ export function buildMigrationPlan(

for (const section of sections) {
for (const element of section.elements) {
const classification = classifyElement(element, section.heading, triggerMap);
const classification = classifyElement(element, section.heading, triggerMap, config);

// Dedup against existing ADF
if (classification.decision === 'MIGRATE' && existingItems.size > 0) {
Expand Down
3 changes: 2 additions & 1 deletion packages/adf/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export { validateConstraints, computeWeightSummary } from './validator';
export { evaluateEvidence } from './evidence';
export type { EvidenceReport, StaleBaselineWarning } from './evidence';
export { parseMarkdownSections } from './markdown-parser';
export type { MarkdownSection, MarkdownElement, RuleStrength } from './markdown-parser';
export type { MarkdownSection, MarkdownElement, RuleStrength, StrengthConfig } from './markdown-parser';
export { classifyElement, isDuplicateItem, buildMigrationPlan } from './content-classifier';
export type {
ClassificationResult,
Expand All @@ -16,6 +16,7 @@ export type {
AdfTargetSection,
WeightTag,
TriggerMap,
ClassifierConfig,
} from './content-classifier';
export * from './types';
export * from './errors';
22 changes: 15 additions & 7 deletions packages/adf/src/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ export interface MarkdownSection {
elements: MarkdownElement[];
}

/** Optional overrides for rule-strength detection patterns. */
export interface StrengthConfig {
imperativePatterns?: RegExp[];
advisoryPatterns?: RegExp[];
}

// ============================================================================
// Strength Detection
// ============================================================================

const IMPERATIVE_PATTERNS = [
const IMPERATIVE_PATTERNS: RegExp[] = [
/\bNEVER\b/,
/\bALWAYS\b/,
/\bMUST\b/,
Expand All @@ -43,7 +49,7 @@ const IMPERATIVE_PATTERNS = [
/\bREQUIRE[DS]?\b/,
];

const ADVISORY_PATTERNS = [
const ADVISORY_PATTERNS: RegExp[] = [
/\bprefer\b/i,
/\bshould\b/i,
/\bbias\b/i,
Expand All @@ -53,11 +59,13 @@ const ADVISORY_PATTERNS = [
/\btry to\b/i,
];

function detectStrength(text: string): RuleStrength {
for (const p of IMPERATIVE_PATTERNS) {
function detectStrength(text: string, config?: StrengthConfig): RuleStrength {
const imp = config?.imperativePatterns ?? IMPERATIVE_PATTERNS;
const adv = config?.advisoryPatterns ?? ADVISORY_PATTERNS;
for (const p of imp) {
if (p.test(text)) return 'imperative';
}
for (const p of ADVISORY_PATTERNS) {
for (const p of adv) {
if (p.test(text)) return 'advisory';
}
return 'neutral';
Expand All @@ -74,7 +82,7 @@ function detectStrength(text: string): RuleStrength {
* "preamble" section with heading "". Within each section, sub-elements
* are classified as rules, code blocks, table rows, or prose.
*/
export function parseMarkdownSections(input: string): MarkdownSection[] {
export function parseMarkdownSections(input: string, config?: StrengthConfig): MarkdownSection[] {
const lines = input.split('\n');
const sections: MarkdownSection[] = [];

Expand Down Expand Up @@ -149,7 +157,7 @@ export function parseMarkdownSections(input: string): MarkdownSection[] {
currentElements.push({
type: 'rule',
content: ruleText,
strength: detectStrength(ruleText),
strength: detectStrength(ruleText, config),
});
continue;
}
Expand Down