import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import YAML from "yaml"; /** * Source registry seeds. `config/sources.yaml` is the founding file; every `config/sources.d/*.yaml` * (sorted by name) is merged on top. A fragment entry may carry `extend: true` to add sensors, * products and aliases to a source declared earlier (same `id`) instead of redefining it — used to * attach connector-class sensors (EDGAR filings, TLS/DNS posture, package registries…) to existing * organizations without touching the founding file. * * This module has no database dependency so that the validator CLI can load seeds anywhere. */ export { sensorSchema, productSchema, sourceSchema, extendSchema } from "@websensor/core"; export type { SensorSeed, SourceSeed } from "@websensor/core"; import { extendSchema, sourceSchema, type SourceSeed } from "@websensor/core"; export interface SeedIssue { file: string; source: string | undefined; message: string; } export interface LoadedSeeds { seeds: SourceSeed[]; issues: SeedIssue[]; files: string[]; /** source id → files that declared or extended it */ origins: Map; } export function listSeedFiles(file: string, dir: string): string[] { const files = existsSync(file) ? [file] : []; if (existsSync(dir) && statSync(dir).isDirectory()) { for (const f of readdirSync(dir).sort()) if (/\.ya?ml$/.test(f) && !f.startsWith("_") && !f.startsWith(".")) files.push(join(dir, f)); } return files; } export function loadSeedsDetailed(file: string, dir: string): LoadedSeeds { const files = listSeedFiles(file, dir); const byId = new Map(); const origins = new Map(); const issues: SeedIssue[] = []; const touch = (id: string, f: string): void => { const arr = origins.get(id) ?? []; if (!arr.includes(f)) arr.push(f); origins.set(id, arr); }; for (const f of files) { let raw: { sources?: unknown[] }; try { raw = (YAML.parse(readFileSync(f, "utf8")) ?? {}) as { sources?: unknown[] }; } catch (e) { issues.push({ file: f, source: undefined, message: `YAML parse error: ${(e as Error).message}` }); continue; } for (const s of raw.sources ?? []) { const id = (s as { id?: string })?.id; if ((s as { extend?: boolean })?.extend === true) { const parsed = extendSchema.safeParse(s); if (!parsed.success) { issues.push({ file: f, source: id, message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }); continue; } const base = byId.get(parsed.data.id); if (!base) { issues.push({ file: f, source: id, message: "extend: true but no source with this id was declared before this file" }); continue; } touch(base.id, f); base.aliases = [...new Set([...base.aliases, ...parsed.data.aliases])]; base.products = [...base.products, ...parsed.data.products.filter((p) => !base.products.some((q) => q.name === p.name))]; base.categories = [...new Set([...base.categories, ...parsed.data.categories])]; base.sensors = [...base.sensors, ...parsed.data.sensors.filter((n) => !base.sensors.some((o) => o.url === n.url))]; if (parsed.data.fallback) base.fallback = { ...base.fallback, ...parsed.data.fallback }; if (parsed.data.notes) base.notes = [base.notes, parsed.data.notes].filter(Boolean).join(" "); if (parsed.data.country && !base.country) base.country = parsed.data.country; if (parsed.data.language && !base.language) base.language = parsed.data.language; if (parsed.data.first_party !== undefined && base.first_party === undefined) base.first_party = parsed.data.first_party; continue; } const parsed = sourceSchema.safeParse(s); if (!parsed.success) { issues.push({ file: f, source: id, message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }); continue; } if (byId.has(parsed.data.id)) { issues.push({ file: f, source: parsed.data.id, message: "duplicate source id (use extend: true to add sensors to an existing source)" }); continue; } byId.set(parsed.data.id, parsed.data); touch(parsed.data.id, f); } } // Sensor-level sanity: duplicate URLs inside a source. for (const s of byId.values()) { const seen = new Set(); for (const sen of s.sensors) { const k = sen.url.replace(/\/$/, ""); if (seen.has(k)) issues.push({ file: "-", source: s.id, message: `duplicate sensor url ${sen.url}` }); seen.add(k); } } return { seeds: [...byId.values()], issues, files, origins }; }