/**
* Methodology library — runtime loader + query API.
*
* Scans every `*.yaml` file under `src/intelligence/methodologies/`,
* parses it into a structured Methodology object, and indexes the
* results by id / type / domain / agent / status. Intelligence agents
* query the loaded library through the helpers exported here; they
* never touch the filesystem or the YAML directly. Adding or
* deprecating a methodology means dropping/editing a YAML file —
* agent code does not change (per Pillar 2 spec §SME Methodology
* Library).
*
* The library is loaded lazily on first query and cached for the
* process lifetime. `reloadLibrary()` clears the cache for tests.
*/
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import YAML from 'yaml';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const METHODOLOGIES_DIR = path.resolve(__dirname, 'methodologies');
/* -------------------------------------------------------------- *
* Types
* -------------------------------------------------------------- */
export type MethodologyType =
| 'metric_definition'
| 'comparison_method'
| 'insight_framework'
| 'normalization_rule';
export type MethodologyStatus = 'active' | 'deprecated' | 'under_review';
export type MethodologyAgent =
| 'analytical_table'
| 'performance_metrics'
| 'comparisons_synthesis'
| 'insight_synthesis';
export interface MethodologyAppliesTo {
readonly agent: MethodologyAgent | string;
readonly triggers: readonly string[];
}
export interface Methodology {
readonly methodology_id: string;
readonly name: string;
readonly type: MethodologyType;
readonly domain: string;
readonly applies_to: MethodologyAppliesTo;
readonly definition: Readonly<Record<string, unknown>>;
readonly comparability_check?: Readonly<Record<string, unknown>>;
readonly inputs: readonly unknown[];
readonly outputs: readonly unknown[];
readonly rationale: string;
readonly declared_by: string;
readonly declared_date: string;
readonly last_reviewed?: string;
readonly status: MethodologyStatus;
/** Filesystem path the entry came from, for the audit trail. */
readonly sourceFile: string;
}
export interface MethodologyQuery {
readonly type?: MethodologyType;
readonly domain?: string;
readonly agent?: MethodologyAgent | string;
/** Free-text triggers; matched as case-insensitive substring against
* each entry's `applies_to.triggers` lines. */
readonly triggers?: readonly string[];
/** Default 'active' only — pass 'any' to include deprecated. */
readonly statusFilter?: MethodologyStatus | 'any';
}
/* -------------------------------------------------------------- *
* Loader (lazy + cached)
* -------------------------------------------------------------- */
let cache: readonly Methodology[] | null = null;
let cachePromise: Promise<readonly Methodology[]> | null = null;
export async function loadLibrary(): Promise<readonly Methodology[]> {
if (cache) return cache;
if (cachePromise) return cachePromise;
cachePromise = (async () => {
const files = await collectYamlFiles(METHODOLOGIES_DIR);
const out: Methodology[] = [];
for (const file of files) {
try {
const raw = await readFile(file, 'utf8');
const parsed = YAML.parse(raw) as Partial<Methodology> | null;
if (!parsed || typeof parsed !== 'object') continue;
const m = normalize(parsed, file);
if (m) out.push(m);
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`[methodology-library] skipped ${path.relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
}
}
cache = out;
return out;
})();
return cachePromise;
}
export function reloadLibrary(): void {
cache = null;
cachePromise = null;
}
/* -------------------------------------------------------------- *
* Query helpers
* -------------------------------------------------------------- */
export async function findMethodologies(query: MethodologyQuery = {}): Promise<readonly Methodology[]> {
const all = await loadLibrary();
const statusFilter = query.statusFilter ?? 'active';
return all.filter(m => {
if (statusFilter !== 'any' && m.status !== statusFilter) return false;
if (query.type && m.type !== query.type) return false;
if (query.domain && m.domain !== query.domain && m.domain !== 'all') return false;
if (query.agent && m.applies_to.agent !== query.agent) return false;
if (query.triggers && query.triggers.length > 0) {
const hay = m.applies_to.triggers.join(' \n ').toLowerCase();
const allMatch = query.triggers.every(t => hay.includes(t.toLowerCase()));
if (!allMatch) return false;
}
return true;
});
}
export async function getMethodology(id: string): Promise<Methodology | null> {
const all = await loadLibrary();
return all.find(m => m.methodology_id === id) ?? null;
}
export async function listIds(): Promise<readonly string[]> {
const all = await loadLibrary();
return all.map(m => m.methodology_id).sort();
}
/* -------------------------------------------------------------- *
* Internals
* -------------------------------------------------------------- */
async function collectYamlFiles(dir: string): Promise<string[]> {
const out: string[] = [];
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return out;
}
for (const name of entries) {
const p = path.join(dir, name);
let s;
try {
s = await stat(p);
} catch {
continue;
}
if (s.isDirectory()) {
out.push(...(await collectYamlFiles(p)));
} else if (s.isFile() && /\.(yaml|yml)$/i.test(name)) {
out.push(p);
}
}
return out.sort();
}
function normalize(raw: Partial<Methodology>, sourceFile: string): Methodology | null {
const id = typeof raw.methodology_id === 'string' ? raw.methodology_id : '';
if (!id) return null;
const type = (raw.type as MethodologyType) ?? 'metric_definition';
const status = (raw.status as MethodologyStatus) ?? 'active';
const applies = raw.applies_to ?? { agent: '', triggers: [] };
const triggers = Array.isArray(applies.triggers) ? applies.triggers.map(t => String(t)) : [];
return {
methodology_id: id,
name: typeof raw.name === 'string' ? raw.name : id,
type,
domain: typeof raw.domain === 'string' ? raw.domain : 'all',
applies_to: {
agent: typeof applies.agent === 'string' ? applies.agent : '',
triggers,
},
definition: (raw.definition && typeof raw.definition === 'object'
? raw.definition
: {}) as Record<string, unknown>,
comparability_check:
raw.comparability_check && typeof raw.comparability_check === 'object'
? (raw.comparability_check as Record<string, unknown>)
: undefined,
inputs: Array.isArray(raw.inputs) ? raw.inputs : [],
outputs: Array.isArray(raw.outputs) ? raw.outputs : [],
rationale: typeof raw.rationale === 'string' ? raw.rationale : '',
declared_by: typeof raw.declared_by === 'string' ? raw.declared_by : '',
declared_date: typeof raw.declared_date === 'string' ? raw.declared_date : '',
last_reviewed: typeof raw.last_reviewed === 'string' ? raw.last_reviewed : undefined,
status,
sourceFile: path.relative(process.cwd(), sourceFile),
};
}