// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-29-p14-yaml-template-refs. // Responsibility: Render YAML-first web-probe sentinel configRefs with node/lane variables and selector paths. import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { rootPath } from "./config"; export interface WebProbeSentinelTemplateContext { readonly nodeId: string; readonly lane: string; readonly runtimeNamespace?: string; } type TemplateVarValue = string | number | boolean; export interface WebProbeSentinelRenderedConfigRef { readonly originalRef: string; readonly ref: string; readonly file: string; readonly path: string; readonly target: unknown; readonly sha256: string; readonly byteCount: number; readonly variableNames: readonly string[]; } export function readWebProbeSentinelConfigRefTarget(context: WebProbeSentinelTemplateContext, ref: string): unknown { return readWebProbeSentinelConfigRef(context, ref).target; } export function readWebProbeSentinelConfigRef(context: WebProbeSentinelTemplateContext, ref: string): WebProbeSentinelRenderedConfigRef { const builtinVars = templateContextVars(context); const renderedRef = renderTemplateString(ref, builtinVars, "configRef"); const parsed = parseRenderedConfigRef(renderedRef); const absPath = rootPath(parsed.file); if (!existsSync(absPath)) throw new Error(`${parsed.file} does not exist`); const text = readFileSync(absPath, "utf8"); const doc = Bun.YAML.parse(text) as unknown; const vars = { ...builtinVars, ...documentVars(doc, builtinVars) }; const path = renderTemplateString(parsed.path, vars, `${parsed.file}#path`); const target = valueAtConfigPath(doc, path); if (target === undefined) throw new Error(`${parsed.file}#${path} is missing`); return { originalRef: ref, ref: `${parsed.file}#${path}`, file: parsed.file, path, target: renderTemplateValue(target, vars), sha256: `sha256:${createHash("sha256").update(text).digest("hex")}`, byteCount: Buffer.byteLength(text), variableNames: Object.keys(vars).sort(), }; } export function renderWebProbeSentinelConfigRefString(context: WebProbeSentinelTemplateContext, ref: string): string { return renderTemplateString(ref, templateContextVars(context), "configRef"); } function templateContextVars(context: WebProbeSentinelTemplateContext): Record { const node = context.nodeId; const nodeLower = node.toLowerCase(); const lane = context.lane; const laneMinor = lane.replace(/^v/u, ""); return { NODE: node, node, nodeLower, NODE_LOWER: nodeLower, LANE: lane, lane, laneMinor, LANE_MINOR: laneMinor, nodeLane: `${nodeLower}-${lane}`, NODE_LANE: `${node}-${lane}`, runtimeNamespace: context.runtimeNamespace ?? "", ...contextTemplateVars(context), }; } function contextTemplateVars(context: WebProbeSentinelTemplateContext): Record { const observability = isRecord((context as { readonly observability?: unknown }).observability) ? (context as { readonly observability: Record }).observability : {}; const webProbe = isRecord(observability.webProbe) ? observability.webProbe : {}; const templateVars = isRecord(webProbe.templateVars) ? webProbe.templateVars : {}; const result: Record = {}; for (const [key, value] of Object.entries(templateVars)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`observability.webProbe.templateVars.${key} is not a supported variable name`); if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { throw new Error(`observability.webProbe.templateVars.${key} must be a scalar`); } result[key] = value; } return result; } function documentVars(doc: unknown, vars: Record): Record { if (!isRecord(doc) || !isRecord(doc.vars)) return {}; const result: Record = {}; for (const [key, value] of Object.entries(doc.vars)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`vars.${key} is not a supported variable name`); if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { throw new Error(`vars.${key} must be a scalar`); } result[key] = typeof value === "string" ? renderTemplateString(value, { ...vars, ...result }, `vars.${key}`) : value; } return result; } function renderTemplateValue(value: unknown, vars: Record): unknown { if (typeof value === "string") return renderTemplateScalar(value, vars, "YAML string"); if (Array.isArray(value)) return value.map((item) => renderTemplateValue(item, vars)); if (!isRecord(value)) return value; const localVars = localTemplateVars(value, vars); const rendered: Record = {}; for (const [key, item] of Object.entries(value)) { if (key === "vars") continue; rendered[key] = renderTemplateValue(item, localVars); } return rendered; } function localTemplateVars(value: Record, parentVars: Record): Record { if (!isRecord(value.vars)) return parentVars; const local: Record = {}; for (const [key, item] of Object.entries(value.vars)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`vars.${key} is not a supported variable name`); if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") throw new Error(`vars.${key} must be a scalar`); local[key] = typeof item === "string" ? renderTemplateString(item, { ...parentVars, ...local }, `vars.${key}`) : item; } return { ...parentVars, ...local }; } function renderTemplateScalar(value: string, vars: Record, label: string): TemplateVarValue { const whole = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/u.exec(value); if (whole !== null) { const rendered = vars[whole[1]]; if (rendered === undefined) throw new Error(`${label} references undefined variable ${whole[1]}`); return rendered; } return renderTemplateString(value, vars, label); } function renderTemplateString(value: string, vars: Record, label: string): string { return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_match, key: string) => { const rendered = vars[key]; if (rendered === undefined) throw new Error(`${label} references undefined variable ${key}`); return String(rendered); }); } function parseRenderedConfigRef(ref: string): { readonly file: string; readonly path: string } { const [file, path, extra] = ref.split("#"); if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) { throw new Error(`${ref} must use path/to/file.yaml#object.path syntax`); } if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) { throw new Error(`${ref} must reference a repo-relative config/*.yaml file without ..`); } return { file, path }; } function valueAtConfigPath(value: unknown, path: string): unknown { let current = value; for (const segment of path.split(".")) { if (segment.length === 0) return undefined; current = valueAtSegment(current, segment); if (current === undefined) return undefined; } return current; } function valueAtSegment(value: unknown, segment: string): unknown { let rest = segment; let current = value; const key = /^[A-Za-z0-9_-]+/u.exec(rest)?.[0] ?? null; if (key !== null) { if (!isRecord(current)) return undefined; current = current[key]; rest = rest.slice(key.length); } if (key === null && !rest.startsWith("[")) return undefined; while (rest.length > 0) { const match = /^\[([^\]]+)\]/u.exec(rest); if (match === null) return undefined; current = valueAtBracket(current, match[1]); if (current === undefined) return undefined; rest = rest.slice(match[0].length); } return current; } function valueAtBracket(value: unknown, selector: string): unknown { if (/^\d+$/u.test(selector)) { if (!Array.isArray(value)) return undefined; return value[Number(selector)]; } const match = /^([A-Za-z0-9_-]+)=([A-Za-z0-9_.:/@+-]+)$/u.exec(selector); if (match === null || !Array.isArray(value)) return undefined; const [, key, expected] = match; return value.find((item) => isRecord(item) && item[key] === expected); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }