diff --git a/packages/adf/src/__tests__/classifier-config.test.ts b/packages/adf/src/__tests__/classifier-config.test.ts new file mode 100644 index 0000000..16c0d04 --- /dev/null +++ b/packages/adf/src/__tests__/classifier-config.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/adf/src/__tests__/strength-config.test.ts b/packages/adf/src/__tests__/strength-config.test.ts new file mode 100644 index 0000000..7937805 --- /dev/null +++ b/packages/adf/src/__tests__/strength-config.test.ts @@ -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'); + }); +}); diff --git a/packages/adf/src/content-classifier.ts b/packages/adf/src/content-classifier.ts index ab300db..74174b5 100644 --- a/packages/adf/src/content-classifier.ts +++ b/packages/adf/src/content-classifier.ts @@ -21,6 +21,12 @@ export type WeightTag = 'load-bearing' | 'advisory'; /** Module path → lowercase trigger keywords for content-based routing. */ export type TriggerMap = Record; +/** Optional overrides for classification heuristics. */ +export interface ClassifierConfig { + stayPatterns?: RegExp[]; + headingRoutes?: Array<{ pattern: RegExp; module: string }>; +} + export interface ClassificationResult { decision: RouteDecision; targetSection: AdfTargetSection; @@ -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'; } @@ -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. @@ -131,7 +144,7 @@ export function classifyElement( } // Check STAY patterns first - if (matchesStayPattern(text)) { + if (matchesStayPattern(text, config?.stayPatterns)) { return { decision: 'STAY', targetSection: 'CONTEXT', @@ -291,6 +304,7 @@ export function buildMigrationPlan( sections: MarkdownSection[], existingAdf?: AdfDocument, triggerMap?: TriggerMap, + config?: ClassifierConfig, ): MigrationPlan { const items: MigrationItem[] = []; @@ -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) { diff --git a/packages/adf/src/index.ts b/packages/adf/src/index.ts index 9824ef4..2856de6 100644 --- a/packages/adf/src/index.ts +++ b/packages/adf/src/index.ts @@ -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, @@ -16,6 +16,7 @@ export type { AdfTargetSection, WeightTag, TriggerMap, + ClassifierConfig, } from './content-classifier'; export * from './types'; export * from './errors'; diff --git a/packages/adf/src/markdown-parser.ts b/packages/adf/src/markdown-parser.ts index 202ec19..3089109 100644 --- a/packages/adf/src/markdown-parser.ts +++ b/packages/adf/src/markdown-parser.ts @@ -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/, @@ -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, @@ -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'; @@ -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[] = []; @@ -149,7 +157,7 @@ export function parseMarkdownSections(input: string): MarkdownSection[] { currentElements.push({ type: 'rule', content: ruleText, - strength: detectStrength(ruleText), + strength: detectStrength(ruleText, config), }); continue; }