diff --git a/packages/adf/src/__tests__/manifest.test.ts b/packages/adf/src/__tests__/manifest.test.ts new file mode 100644 index 0000000..cbb4253 --- /dev/null +++ b/packages/adf/src/__tests__/manifest.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { isKeywordMatch, buildTriggerReport } from '../manifest'; +import type { Manifest } from '../types'; + +describe('isKeywordMatch', () => { + it('matches exact keywords', () => { + expect(isKeywordMatch('react', 'react')).toBe(true); + }); + + it('rejects different keywords', () => { + expect(isKeywordMatch('react', 'vue')).toBe(false); + }); + + it('matches prefix stem when trigger is prefix of keyword', () => { + // "react" (5 chars) is prefix of "reacting" (8 chars), ratio 5/8 = 0.625 < 0.66 → no match + expect(isKeywordMatch('react', 'reacting')).toBe(false); + // "node" (4 chars) is prefix of "nodes" (5 chars), ratio 4/5 = 0.80 >= 0.66 → match + expect(isKeywordMatch('node', 'nodes')).toBe(true); + }); + + it('matches prefix stem when keyword is prefix of trigger', () => { + // "data" (4 chars) is prefix of "database" (8 chars), ratio 4/8 = 0.50 < 0.66 → no match + expect(isKeywordMatch('database', 'data')).toBe(false); + // "api" (3 chars) — too short (<4), no prefix match + expect(isKeywordMatch('apis', 'api')).toBe(false); + }); + + it('requires minimum 4 chars for prefix matching', () => { + // "css" (3 chars) prefix of "cssx" — too short + expect(isKeywordMatch('css', 'cssx')).toBe(false); + }); + + it('rejects when length ratio is below 66%', () => { + // "test" (4 chars) prefix of "testing123" (10 chars), ratio 4/10 = 0.40 < 0.66 + expect(isKeywordMatch('test', 'testing123')).toBe(false); + }); +}); + +describe('buildTriggerReport', () => { + const manifest: Manifest = { + version: '0.1', + defaultLoad: ['core.adf'], + onDemand: [ + { path: 'frontend.adf', triggers: ['React', 'CSS'], loadPolicy: 'ON_DEMAND' }, + { path: 'backend.adf', triggers: ['API', 'Node'], loadPolicy: 'ON_DEMAND' }, + ], + rules: [], + sync: [], + cadence: [], + metrics: [], + }; + + it('reports matched triggers with keywords', () => { + const report = buildTriggerReport(manifest, ['core.adf', 'frontend.adf'], ['React']); + const reactEntry = report.find(r => r.trigger === 'React'); + expect(reactEntry).toBeDefined(); + expect(reactEntry!.matched).toBe(true); + expect(reactEntry!.matchedKeywords).toEqual(['react']); + expect(reactEntry!.loadReason).toBe('trigger'); + }); + + it('reports unmatched triggers with empty keywords', () => { + const report = buildTriggerReport(manifest, ['core.adf'], ['React']); + const apiEntry = report.find(r => r.trigger === 'API'); + expect(apiEntry).toBeDefined(); + expect(apiEntry!.matched).toBe(false); + expect(apiEntry!.matchedKeywords).toEqual([]); + }); + + it('includes all triggers from all on-demand modules', () => { + const report = buildTriggerReport(manifest, ['core.adf'], []); + expect(report).toHaveLength(4); // React, CSS, API, Node + }); +}); diff --git a/packages/adf/src/__tests__/merger.test.ts b/packages/adf/src/__tests__/merger.test.ts new file mode 100644 index 0000000..aa6c7fa --- /dev/null +++ b/packages/adf/src/__tests__/merger.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { mergeDocuments, estimateTokens } from '../merger'; +import type { AdfDocument } from '../types'; + +describe('mergeDocuments', () => { + it('merges list sections by concatenating items', () => { + const doc1: AdfDocument = { + version: '0.1', + sections: [{ key: 'RULES', decoration: null, content: { type: 'list', items: ['Rule A'] } }], + }; + const doc2: AdfDocument = { + version: '0.1', + sections: [{ key: 'RULES', decoration: null, content: { type: 'list', items: ['Rule B'] } }], + }; + const merged = mergeDocuments([doc1, doc2]); + const rules = merged.sections.find(s => s.key === 'RULES'); + expect(rules).toBeDefined(); + expect(rules!.content).toEqual({ type: 'list', items: ['Rule A', 'Rule B'] }); + }); + + it('merges map sections by concatenating entries', () => { + const doc1: AdfDocument = { + version: '0.1', + sections: [{ key: 'STATE', decoration: null, content: { type: 'map', entries: [{ key: 'A', value: '1' }] } }], + }; + const doc2: AdfDocument = { + version: '0.1', + sections: [{ key: 'STATE', decoration: null, content: { type: 'map', entries: [{ key: 'B', value: '2' }] } }], + }; + const merged = mergeDocuments([doc1, doc2]); + const state = merged.sections.find(s => s.key === 'STATE'); + expect(state!.content).toEqual({ type: 'map', entries: [{ key: 'A', value: '1' }, { key: 'B', value: '2' }] }); + }); + + it('merges text sections by joining with newline', () => { + const doc1: AdfDocument = { + version: '0.1', + sections: [{ key: 'ROLE', decoration: null, content: { type: 'text', value: 'Part 1' } }], + }; + const doc2: AdfDocument = { + version: '0.1', + sections: [{ key: 'ROLE', decoration: null, content: { type: 'text', value: 'Part 2' } }], + }; + const merged = mergeDocuments([doc1, doc2]); + const role = merged.sections.find(s => s.key === 'ROLE'); + expect(role!.content).toEqual({ type: 'text', value: 'Part 1\nPart 2' }); + }); + + it('merges metric sections by concatenating entries', () => { + const doc1: AdfDocument = { + version: '0.1', + sections: [{ key: 'METRICS', decoration: null, content: { type: 'metric', entries: [{ key: 'loc', value: 100, ceiling: 200, unit: 'lines' }] } }], + }; + const doc2: AdfDocument = { + version: '0.1', + sections: [{ key: 'METRICS', decoration: null, content: { type: 'metric', entries: [{ key: 'fns', value: 10, ceiling: 20, unit: 'count' }] } }], + }; + const merged = mergeDocuments([doc1, doc2]); + const metrics = merged.sections.find(s => s.key === 'METRICS'); + expect(metrics!.content.type).toBe('metric'); + if (metrics!.content.type === 'metric') { + expect(metrics!.content.entries).toHaveLength(2); + } + }); + + it('keeps target content for mismatched types (first-wins)', () => { + const doc1: AdfDocument = { + version: '0.1', + sections: [{ key: 'DATA', decoration: null, content: { type: 'text', value: 'text' } }], + }; + const doc2: AdfDocument = { + version: '0.1', + sections: [{ key: 'DATA', decoration: null, content: { type: 'list', items: ['item'] } }], + }; + const merged = mergeDocuments([doc1, doc2]); + const data = merged.sections.find(s => s.key === 'DATA'); + expect(data!.content).toEqual({ type: 'text', value: 'text' }); + }); + + it('promotes weight to load-bearing when either source is load-bearing', () => { + const doc1: AdfDocument = { + version: '0.1', + sections: [{ key: 'RULES', decoration: null, content: { type: 'list', items: ['A'] }, weight: 'advisory' }], + }; + const doc2: AdfDocument = { + version: '0.1', + sections: [{ key: 'RULES', decoration: null, content: { type: 'list', items: ['B'] }, weight: 'load-bearing' }], + }; + const merged = mergeDocuments([doc1, doc2]); + expect(merged.sections[0].weight).toBe('load-bearing'); + }); + + it('does not mutate input documents', () => { + const doc1: AdfDocument = { + version: '0.1', + sections: [{ key: 'RULES', decoration: null, content: { type: 'list', items: ['A'] } }], + }; + const doc2: AdfDocument = { + version: '0.1', + sections: [{ key: 'RULES', decoration: null, content: { type: 'list', items: ['B'] } }], + }; + mergeDocuments([doc1, doc2]); + expect(doc1.sections[0].content).toEqual({ type: 'list', items: ['A'] }); + }); +}); + +describe('estimateTokens', () => { + it('returns positive token count for non-empty document', () => { + const doc: AdfDocument = { + version: '0.1', + sections: [ + { key: 'RULES', decoration: null, content: { type: 'list', items: ['Use TypeScript', 'Follow conventions'] } }, + ], + }; + const tokens = estimateTokens(doc); + expect(tokens).toBeGreaterThan(0); + }); + + it('returns 0 for empty document', () => { + const doc: AdfDocument = { version: '0.1', sections: [] }; + expect(estimateTokens(doc)).toBe(0); + }); +}); diff --git a/packages/adf/src/bundler.ts b/packages/adf/src/bundler.ts index 1e84d42..eebcaf7 100644 --- a/packages/adf/src/bundler.ts +++ b/packages/adf/src/bundler.ts @@ -1,204 +1,26 @@ /** - * ADF Bundler — manifest parsing, module resolution, and context merging. + * ADF Bundler — orchestrates module resolution, loading, and merging. * * Reads a manifest.adf to determine which modules to load for a given task, - * resolves ON_DEMAND modules via keyword matching, and merges into a single - * ADF document. + * delegates to manifest.ts for resolution and merger.ts for document merging, + * and assembles the final BundleResult. */ import type { AdfDocument, - AdfSection, Manifest, - ManifestModule, - MetricSource, - SyncEntry, - CadenceEntry, BundleResult, } from './types'; import { AdfBundleError } from './errors'; import { parseAdf } from './parser'; +import { parseManifest, resolveModules, buildTriggerReport } from './manifest'; +import { mergeDocuments, estimateTokens } from './merger'; -// ============================================================================ -// Manifest Parsing -// ============================================================================ - -/** - * Extract a Manifest from a parsed ADF document (manifest.adf). - */ -export function parseManifest(doc: AdfDocument): Manifest { - const manifest: Manifest = { - version: doc.version, - defaultLoad: [], - onDemand: [], - rules: [], - sync: [], - cadence: [], - metrics: [], - }; - - for (const section of doc.sections) { - switch (section.key) { - case 'ROLE': { - if (section.content.type === 'text') { - manifest.role = section.content.value; - } - break; - } - case 'DEFAULT_LOAD': { - if (section.content.type === 'list') { - manifest.defaultLoad = section.content.items.map(i => i.trim()); - } - break; - } - case 'ON_DEMAND': { - if (section.content.type === 'list') { - manifest.onDemand = section.content.items.map(parseTriggerEntry); - } - break; - } - case 'RULES': { - if (section.content.type === 'list') { - manifest.rules = section.content.items.map(i => i.trim()); - } - break; - } - case 'SYNC': { - if (section.content.type === 'list') { - manifest.sync = section.content.items.map(parseSyncEntry).filter((e): e is SyncEntry => e !== null); - } - break; - } - case 'CADENCE': { - if (section.content.type === 'map') { - manifest.cadence = section.content.entries.map(e => ({ - check: e.key, - frequency: e.value, - })); - } - break; - } - case 'METRICS': { - if (section.content.type === 'map') { - manifest.metrics = section.content.entries.map(e => ({ - key: e.key, - path: e.value, - })); - } - break; - } - case 'BUDGET': { - if (section.content.type === 'map') { - const maxTokens = section.content.entries.find(e => e.key === 'MAX_TOKENS'); - if (maxTokens) { - const parsed = parseInt(maxTokens.value, 10); - if (!isNaN(parsed)) { - manifest.tokenBudget = parsed; - } - } - } - break; - } - } - } - - return manifest; -} - -/** - * Parse a SYNC entry like: - * "governance.adf -> src/adf-read.ts" - */ -function parseSyncEntry(entry: string): SyncEntry | null { - const match = entry.match(/^(.+?)\s*->\s*(.+)$/); - if (!match) return null; - return { source: match[1].trim(), target: match[2].trim() }; -} - -/** - * Parse a single ON_DEMAND entry like: - * "frontend.adf (Triggers on: React, CSS, UI)" - * "frontend.adf (Triggers on: React, CSS, UI) [budget: 1200]" - */ -function parseTriggerEntry(entry: string): ManifestModule { - // Extract optional [budget: N] suffix first - let remaining = entry; - let tokenBudget: number | undefined; - const budgetMatch = remaining.match(/\s*\[budget\s*:\s*(\d+)\]\s*$/i); - if (budgetMatch) { - tokenBudget = parseInt(budgetMatch[1], 10); - remaining = remaining.slice(0, budgetMatch.index!).trim(); - } - - const triggerMatch = remaining.match(/^(.+?)\s*\(Triggers?\s+on\s*:\s*(.+)\)\s*$/i); - if (triggerMatch) { - const path = triggerMatch[1].trim(); - const triggers = triggerMatch[2] - .split(',') - .map(t => t.trim()) - .filter(t => t.length > 0); - const mod: ManifestModule = { path, triggers, loadPolicy: 'ON_DEMAND' }; - if (tokenBudget !== undefined) mod.tokenBudget = tokenBudget; - return mod; - } - - // No trigger syntax — just a path (possibly with budget) - const mod: ManifestModule = { path: remaining.trim(), triggers: [], loadPolicy: 'ON_DEMAND' }; - if (tokenBudget !== undefined) mod.tokenBudget = tokenBudget; - return mod; -} - -// ============================================================================ -// Module Resolution -// ============================================================================ - -/** - * Resolve which modules to load given a manifest and task keywords. - * Always includes defaultLoad modules; adds ON_DEMAND modules whose - * triggers match any keyword (case-insensitive). - */ -export function resolveModules(manifest: Manifest, taskKeywords: string[]): string[] { - const resolved = new Set(manifest.defaultLoad); - const lowerKeywords = taskKeywords.map(k => k.toLowerCase()); - - for (const mod of manifest.onDemand) { - for (const trigger of mod.triggers) { - if (matchesTrigger(trigger, lowerKeywords)) { - resolved.add(mod.path); - break; - } - } - } - - return [...resolved]; -} - -/** - * Check if a single trigger matches a single keyword via exact or prefix stemming. - * Prefix match requires minimum 4 chars and >=66% length ratio. - */ -function isKeywordMatch(trigger: string, keyword: string): boolean { - if (keyword === trigger) return true; - if (trigger.length >= 4 && keyword.startsWith(trigger) && isPrefixStem(trigger, keyword)) return true; - if (keyword.length >= 4 && trigger.startsWith(keyword) && isPrefixStem(keyword, trigger)) return true; - return false; -} - -/** - * Match a trigger against task keywords with prefix stemming. - */ -function matchesTrigger(trigger: string, keywords: string[]): boolean { - const t = trigger.toLowerCase(); - return keywords.some(k => isKeywordMatch(t, k)); -} - -/** Check if prefix is plausibly a stem of the full word (>=66% length ratio). */ -function isPrefixStem(prefix: string, full: string): boolean { - return prefix.length / full.length >= 0.66; -} +// Re-export for backward compatibility +export { parseManifest, resolveModules } from './manifest'; // ============================================================================ -// Bundle Merging +// Bundle Orchestration // ============================================================================ /** @@ -296,116 +118,6 @@ function loadAndParseManifest(basePath: string, readFile: (p: string) => string) return parseManifest(doc); } -function buildTriggerReport( - manifest: Manifest, - resolvedPaths: string[], - taskKeywords: string[], -): BundleResult['triggerMatches'] { - const matches: BundleResult['triggerMatches'] = []; - const lowerKeywords = taskKeywords.map(k => k.toLowerCase()); - const defaultLoadSet = new Set(manifest.defaultLoad); - - for (const mod of manifest.onDemand) { - for (const trigger of mod.triggers) { - const t = trigger.toLowerCase(); - const matchedKeywords = lowerKeywords.filter(k => isKeywordMatch(t, k)); - const isResolved = resolvedPaths.includes(mod.path); - const isDefault = defaultLoadSet.has(mod.path); - matches.push({ - module: mod.path, - trigger, - matched: isResolved, - matchedKeywords, - loadReason: isDefault ? 'default' : 'trigger', - }); - } - } - return matches; -} - -/** - * Merge multiple ADF documents into one. - * Duplicate section keys are merged: lists concatenated, texts joined, - * maps concatenated, metrics concatenated. - */ -function mergeDocuments(docs: AdfDocument[]): AdfDocument { - const sectionMap = new Map(); - - for (const doc of docs) { - for (const section of doc.sections) { - const existing = sectionMap.get(section.key); - if (!existing) { - // Deep clone to avoid mutation - sectionMap.set(section.key, JSON.parse(JSON.stringify(section))); - } else { - mergeSectionContent(existing, section); - } - } - } - - return { - version: '0.1', - sections: [...sectionMap.values()], - }; -} - -function mergeSectionContent(target: AdfSection, source: AdfSection): void { - if (target.content.type === 'list' && source.content.type === 'list') { - target.content.items.push(...source.content.items); - } else if (target.content.type === 'map' && source.content.type === 'map') { - target.content.entries.push(...source.content.entries); - } else if (target.content.type === 'text' && source.content.type === 'text') { - if (target.content.value && source.content.value) { - target.content.value = target.content.value + '\n' + source.content.value; - } else if (source.content.value) { - target.content.value = source.content.value; - } - } else if (target.content.type === 'metric' && source.content.type === 'metric') { - target.content.entries.push(...source.content.entries); - } - // Mismatched types: keep target content as-is (first-wins) - - // Promote weight: if either is load-bearing, result is load-bearing - if (source.weight === 'load-bearing' || target.weight === 'load-bearing') { - target.weight = 'load-bearing'; - } else if (source.weight === 'advisory' && !target.weight) { - target.weight = 'advisory'; - } -} - -/** - * Rough token estimate: ~4 chars per token for English text. - */ -function estimateTokens(doc: AdfDocument): number { - let charCount = 0; - for (const section of doc.sections) { - charCount += section.key.length + 2; // key + colon + space - switch (section.content.type) { - case 'text': - charCount += section.content.value.length; - break; - case 'list': - for (const item of section.content.items) { - charCount += item.length + 4; // dash + space + newline - } - break; - case 'map': - for (const entry of section.content.entries) { - charCount += entry.key.length + entry.value.length + 4; - } - break; - case 'metric': - for (const entry of section.content.entries) { - // key: value / ceiling [unit] - charCount += entry.key.length + String(entry.value).length + - String(entry.ceiling).length + entry.unit.length + 8; - } - break; - } - } - return Math.ceil(charCount / 4); -} - function joinPath(base: string, relative: string): string { if (base.endsWith('/')) return base + relative; return base + '/' + relative; diff --git a/packages/adf/src/manifest.ts b/packages/adf/src/manifest.ts new file mode 100644 index 0000000..647aa70 --- /dev/null +++ b/packages/adf/src/manifest.ts @@ -0,0 +1,228 @@ +/** + * ADF Manifest — parsing, trigger resolution, and module routing. + * + * Extracts structured manifest data from parsed ADF documents, resolves + * which ON_DEMAND modules to load for a given task, and produces trigger + * match reports for observability. + */ + +import type { + AdfDocument, + Manifest, + ManifestModule, + SyncEntry, + BundleResult, +} from './types'; + +// ============================================================================ +// Manifest Parsing +// ============================================================================ + +/** + * Extract a Manifest from a parsed ADF document (manifest.adf). + */ +export function parseManifest(doc: AdfDocument): Manifest { + const manifest: Manifest = { + version: doc.version, + defaultLoad: [], + onDemand: [], + rules: [], + sync: [], + cadence: [], + metrics: [], + }; + + for (const section of doc.sections) { + switch (section.key) { + case 'ROLE': { + if (section.content.type === 'text') { + manifest.role = section.content.value; + } + break; + } + case 'DEFAULT_LOAD': { + if (section.content.type === 'list') { + manifest.defaultLoad = section.content.items.map(i => i.trim()); + } + break; + } + case 'ON_DEMAND': { + if (section.content.type === 'list') { + manifest.onDemand = section.content.items.map(parseTriggerEntry); + } + break; + } + case 'RULES': { + if (section.content.type === 'list') { + manifest.rules = section.content.items.map(i => i.trim()); + } + break; + } + case 'SYNC': { + if (section.content.type === 'list') { + manifest.sync = section.content.items.map(parseSyncEntry).filter((e): e is SyncEntry => e !== null); + } + break; + } + case 'CADENCE': { + if (section.content.type === 'map') { + manifest.cadence = section.content.entries.map(e => ({ + check: e.key, + frequency: e.value, + })); + } + break; + } + case 'METRICS': { + if (section.content.type === 'map') { + manifest.metrics = section.content.entries.map(e => ({ + key: e.key, + path: e.value, + })); + } + break; + } + case 'BUDGET': { + if (section.content.type === 'map') { + const maxTokens = section.content.entries.find(e => e.key === 'MAX_TOKENS'); + if (maxTokens) { + const parsed = parseInt(maxTokens.value, 10); + if (!isNaN(parsed)) { + manifest.tokenBudget = parsed; + } + } + } + break; + } + } + } + + return manifest; +} + +/** + * Parse a SYNC entry like: + * "governance.adf -> src/adf-read.ts" + */ +function parseSyncEntry(entry: string): SyncEntry | null { + const match = entry.match(/^(.+?)\s*->\s*(.+)$/); + if (!match) return null; + return { source: match[1].trim(), target: match[2].trim() }; +} + +/** + * Parse a single ON_DEMAND entry like: + * "frontend.adf (Triggers on: React, CSS, UI)" + * "frontend.adf (Triggers on: React, CSS, UI) [budget: 1200]" + */ +function parseTriggerEntry(entry: string): ManifestModule { + // Extract optional [budget: N] suffix first + let remaining = entry; + let tokenBudget: number | undefined; + const budgetMatch = remaining.match(/\s*\[budget\s*:\s*(\d+)\]\s*$/i); + if (budgetMatch) { + tokenBudget = parseInt(budgetMatch[1], 10); + remaining = remaining.slice(0, budgetMatch.index!).trim(); + } + + const triggerMatch = remaining.match(/^(.+?)\s*\(Triggers?\s+on\s*:\s*(.+)\)\s*$/i); + if (triggerMatch) { + const path = triggerMatch[1].trim(); + const triggers = triggerMatch[2] + .split(',') + .map(t => t.trim()) + .filter(t => t.length > 0); + const mod: ManifestModule = { path, triggers, loadPolicy: 'ON_DEMAND' }; + if (tokenBudget !== undefined) mod.tokenBudget = tokenBudget; + return mod; + } + + // No trigger syntax — just a path (possibly with budget) + const mod: ManifestModule = { path: remaining.trim(), triggers: [], loadPolicy: 'ON_DEMAND' }; + if (tokenBudget !== undefined) mod.tokenBudget = tokenBudget; + return mod; +} + +// ============================================================================ +// Module Resolution +// ============================================================================ + +/** + * Resolve which modules to load given a manifest and task keywords. + * Always includes defaultLoad modules; adds ON_DEMAND modules whose + * triggers match any keyword (case-insensitive). + */ +export function resolveModules(manifest: Manifest, taskKeywords: string[]): string[] { + const resolved = new Set(manifest.defaultLoad); + const lowerKeywords = taskKeywords.map(k => k.toLowerCase()); + + for (const mod of manifest.onDemand) { + for (const trigger of mod.triggers) { + if (matchesTrigger(trigger, lowerKeywords)) { + resolved.add(mod.path); + break; + } + } + } + + return [...resolved]; +} + +/** + * Check if a single trigger matches a single keyword via exact or prefix stemming. + * Prefix match requires minimum 4 chars and >=66% length ratio. + */ +export function isKeywordMatch(trigger: string, keyword: string): boolean { + if (keyword === trigger) return true; + if (trigger.length >= 4 && keyword.startsWith(trigger) && isPrefixStem(trigger, keyword)) return true; + if (keyword.length >= 4 && trigger.startsWith(keyword) && isPrefixStem(keyword, trigger)) return true; + return false; +} + +/** + * Match a trigger against task keywords with prefix stemming. + */ +function matchesTrigger(trigger: string, keywords: string[]): boolean { + const t = trigger.toLowerCase(); + return keywords.some(k => isKeywordMatch(t, k)); +} + +/** Check if prefix is plausibly a stem of the full word (>=66% length ratio). */ +function isPrefixStem(prefix: string, full: string): boolean { + return prefix.length / full.length >= 0.66; +} + +// ============================================================================ +// Trigger Reporting +// ============================================================================ + +/** + * Build a trigger match report for observability. + * Shows which triggers matched which keywords and why each module was loaded. + */ +export function buildTriggerReport( + manifest: Manifest, + resolvedPaths: string[], + taskKeywords: string[], +): BundleResult['triggerMatches'] { + const matches: BundleResult['triggerMatches'] = []; + const lowerKeywords = taskKeywords.map(k => k.toLowerCase()); + const defaultLoadSet = new Set(manifest.defaultLoad); + + for (const mod of manifest.onDemand) { + for (const trigger of mod.triggers) { + const t = trigger.toLowerCase(); + const matchedKeywords = lowerKeywords.filter(k => isKeywordMatch(t, k)); + const isResolved = resolvedPaths.includes(mod.path); + const isDefault = defaultLoadSet.has(mod.path); + matches.push({ + module: mod.path, + trigger, + matched: isResolved, + matchedKeywords, + loadReason: isDefault ? 'default' : 'trigger', + }); + } + } + return matches; +} diff --git a/packages/adf/src/merger.ts b/packages/adf/src/merger.ts new file mode 100644 index 0000000..88a3b34 --- /dev/null +++ b/packages/adf/src/merger.ts @@ -0,0 +1,99 @@ +/** + * ADF Merger — pure document merge logic and token estimation. + * + * Merges multiple ADF documents into one by combining sections with + * matching keys. Provides rough token estimation for budget tracking. + */ + +import type { AdfDocument, AdfSection } from './types'; + +// ============================================================================ +// Document Merging +// ============================================================================ + +/** + * Merge multiple ADF documents into one. + * Duplicate section keys are merged: lists concatenated, texts joined, + * maps concatenated, metrics concatenated. + */ +export function mergeDocuments(docs: AdfDocument[]): AdfDocument { + const sectionMap = new Map(); + + for (const doc of docs) { + for (const section of doc.sections) { + const existing = sectionMap.get(section.key); + if (!existing) { + // Deep clone to avoid mutation + sectionMap.set(section.key, JSON.parse(JSON.stringify(section))); + } else { + mergeSectionContent(existing, section); + } + } + } + + return { + version: '0.1', + sections: [...sectionMap.values()], + }; +} + +function mergeSectionContent(target: AdfSection, source: AdfSection): void { + if (target.content.type === 'list' && source.content.type === 'list') { + target.content.items.push(...source.content.items); + } else if (target.content.type === 'map' && source.content.type === 'map') { + target.content.entries.push(...source.content.entries); + } else if (target.content.type === 'text' && source.content.type === 'text') { + if (target.content.value && source.content.value) { + target.content.value = target.content.value + '\n' + source.content.value; + } else if (source.content.value) { + target.content.value = source.content.value; + } + } else if (target.content.type === 'metric' && source.content.type === 'metric') { + target.content.entries.push(...source.content.entries); + } + // Mismatched types: keep target content as-is (first-wins) + + // Promote weight: if either is load-bearing, result is load-bearing + if (source.weight === 'load-bearing' || target.weight === 'load-bearing') { + target.weight = 'load-bearing'; + } else if (source.weight === 'advisory' && !target.weight) { + target.weight = 'advisory'; + } +} + +// ============================================================================ +// Token Estimation +// ============================================================================ + +/** + * Rough token estimate: ~4 chars per token for English text. + */ +export function estimateTokens(doc: AdfDocument): number { + let charCount = 0; + for (const section of doc.sections) { + charCount += section.key.length + 2; // key + colon + space + switch (section.content.type) { + case 'text': + charCount += section.content.value.length; + break; + case 'list': + for (const item of section.content.items) { + charCount += item.length + 4; // dash + space + newline + } + break; + case 'map': + for (const entry of section.content.entries) { + charCount += entry.key.length + entry.value.length + 4; + } + break; + case 'metric': + for (const entry of section.content.entries) { + // key: value / ceiling [unit] + charCount += entry.key.length + String(entry.value).length + + String(entry.ceiling).length + entry.unit.length + 8; + } + break; + } + } + return Math.ceil(charCount / 4); +}