/** * Shared pure helpers for the company-context extension (config loading, * normalization, remote fetch, template rendering). Kept free of the pi API * so it can be unit-tested directly (see test/guardrails.test.ts). * * CONFIGURE: values live in config/company.json; wording in company-context.md. */ import * as fs from "node:fs"; export interface CompanyConfig { gitHost: string; trackerUrl: string; docsUrl: string; repoMap: string; rules: string[]; remoteConfigUrl: string | null; } export const DEFAULT_CONFIG: CompanyConfig = { gitHost: "TODO", trackerUrl: "TODO", docsUrl: "TODO", repoMap: "TODO", rules: [], remoteConfigUrl: null, }; export const MAX_CONTEXT_LINES = 40; /** Keep only known keys and coerce types, so junk in remote/local JSON is ignored. */ export function normalize(raw: Record): CompanyConfig { const str = (v: unknown, d: string) => (typeof v === "string" ? v : d); return { gitHost: str(raw.gitHost, DEFAULT_CONFIG.gitHost), trackerUrl: str(raw.trackerUrl, DEFAULT_CONFIG.trackerUrl), docsUrl: str(raw.docsUrl, DEFAULT_CONFIG.docsUrl), repoMap: str(raw.repoMap, DEFAULT_CONFIG.repoMap), rules: Array.isArray(raw.rules) ? raw.rules.filter((r): r is string => typeof r === "string") : [], remoteConfigUrl: typeof raw.remoteConfigUrl === "string" ? raw.remoteConfigUrl : null, }; } /** Read and validate a local config file, falling back to defaults on any error. */ export function readLocalConfig(configPath: string): CompanyConfig { try { const raw = JSON.parse(fs.readFileSync(configPath, "utf8")); return normalize(raw); } catch { return { ...DEFAULT_CONFIG }; } } /** * Try to fetch a fresh config from a URL. Returns null on any failure. * `fetchImpl` is injectable for tests (defaults to the global fetch). */ export async function fetchRemoteConfig( url: string, fetchImpl: typeof fetch = fetch, timeoutMs = 2500, ): Promise { try { const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) }); if (!res.ok) return null; const raw = (await res.json()) as Record; return normalize(raw); } catch { return null; // network error, timeout, bad JSON — fall back silently } } /** Read the context template; fall back to a minimal inline template. */ export function loadTemplate(templatePath: string): string { try { return fs.readFileSync(templatePath, "utf8"); } catch { return [ "# Корпоративный контекст", "git-хост: {{gitHost}}", "трекер: {{trackerUrl}}", "документация: {{docsUrl}}", "репозитории: {{repoMap}}", "правила:", "{{rules}}", ].join("\n"); } } export interface RenderedContext { text: string; truncated: boolean; } /** * Render the corporate context from the template + effective config. * Reports whether the output was capped at MAX_CONTEXT_LINES so callers can * warn instead of silently dropping rules. */ export function renderContext(template: string, cfg: CompanyConfig): RenderedContext { const rulesBlock = cfg.rules.length > 0 ? cfg.rules.map((r) => `- ${r}`).join("\n") : "- (правила не заданы)"; const rendered = template .replaceAll("{{gitHost}}", cfg.gitHost) .replaceAll("{{trackerUrl}}", cfg.trackerUrl) .replaceAll("{{docsUrl}}", cfg.docsUrl) .replaceAll("{{repoMap}}", cfg.repoMap) .replaceAll("{{rules}}", rulesBlock); const lines = rendered.split("\n"); const truncated = lines.length > MAX_CONTEXT_LINES; const text = lines.slice(0, MAX_CONTEXT_LINES).join("\n").trimEnd(); return { text, truncated }; }