/** * Documentation drift for the Stack Dossier: warns when operator-authored * documentation no longer matches the facts Sencho can observe. This first * version is deterministic and read-only, and checks one thing: a port written * into the dossier `access_urls` that no service actually publishes (the Plex * "moved from :32400 to :32401" case, and "an access URL with no matching * published port"). No prose is interpreted and no AI is involved. * * It compares against exactly the published ports the Anatomy panel shows (the * `anatomy.ports` in "generated facts", rendered directly above these warnings), * so a warning can never contradict the visible facts in the same tab. That * means it inherits the frontend parser's one-part convention: `ports: ["80"]` * reads as host `80` here, matching the panel, even though a bare one-part * mapping actually publishes to an ephemeral host port. Reconciling that is a * separate, panel-wide concern and out of scope for this check. */ import type { AnatomyMarkdownInput } from './anatomyMarkdown'; export interface DocDriftFinding { /** Discriminant mirroring the backend `StackDriftFinding`, so a later check can extend this into a union. */ kind: 'access-url-port-unpublished'; /** The port written into an access URL that nothing publishes. */ port: number; /** The dossier `access_urls` line the port came from. */ source: string; /** Specific, actionable description of what to review. */ detail: string; } /** A published port: a single port or an inclusive `[low, high]` range. */ type PortSpec = number | [number, number]; function validPort(raw: string): number | null { if (!/^\d+$/.test(raw)) return null; const n = Number(raw); return n >= 1 && n <= 65535 ? n : null; } /** * The explicit host port an access-URL line points at, or null when there is * nothing to check. Scheme-default ports (`:80` on http, `:443` on https) are * intentionally skipped: the URL API normalizes them to '' so a documented * default port never produces a warning. */ export function extractExplicitAccessPort(line: string): number | null { const s = line.trim(); if (!s) return null; // Absolute URL (a real `scheme://`): parse directly. `url.port` is '' for an // omitted or scheme-default port, and the API isolates the port from any // `user:pass@` userinfo and from IPv6 brackets. if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) { try { const { port } = new URL(s); return port ? validPort(port) : null; } catch { return null; } } // Bare `host:port` (no scheme). `new URL('host:port')` would misread it as // `scheme:opaque` with no port, so prefix a scheme and require a host-ish // authority. Accepts `192.168.1.5:32400`, `localhost:8080`, `[::1]:8080`; // rejects prose like `note:8080` (host `note`) or `ratio 16:9` (throws). // A bare single-label host (`plex:32400`) is indistinguishable from prose and // is intentionally not checked here; writing it with a scheme (`http://plex:32400`) // routes it through the absolute branch above, where it is checked. try { const url = new URL(`http://${s}`); if (!url.port) return null; const host = url.hostname; const hostish = host.includes('.') || host === 'localhost' || host.startsWith('['); return hostish ? validPort(url.port) : null; } catch { return null; } } function parsePortSpec(host: string): PortSpec | null { const h = host.trim(); const range = h.match(/^(\d+)-(\d+)$/); if (range) { const lo = validPort(range[1]); const hi = validPort(range[2]); return lo !== null && hi !== null && lo <= hi ? [lo, hi] : null; } return validPort(h); } /** * The published TCP ports the panel shows, as single/range specs (an http(s) * access URL cannot be served by a UDP publish). An empty `host` is a * container-only port and is skipped. `indeterminate` is set when a TCP port is * published through an unresolved variable (e.g. `${PLEX_PORT}:32400`): its real * value is unknown, so any documented port might match it and the caller must * not flag, to avoid a false positive. */ function publishedPortModel(anatomy: AnatomyMarkdownInput): { specs: PortSpec[]; indeterminate: boolean } { const specs: PortSpec[] = []; let indeterminate = false; for (const rows of Object.values(anatomy.ports)) { for (const row of rows) { if (row.proto && row.proto.toLowerCase() !== 'tcp') continue; const host = row.host.trim(); if (!host) continue; const spec = parsePortSpec(host); if (spec === null) indeterminate = true; else specs.push(spec); } } return { specs, indeterminate }; } function isPublished(port: number, specs: PortSpec[]): boolean { return specs.some((s) => (typeof s === 'number' ? s === port : port >= s[0] && port <= s[1])); } /** * Compares ports written into the dossier `access_urls` against the stack's * published ports and returns one finding per documented port that nothing * publishes. Pure and deterministic: findings are deduped per port and sorted * by port then source. Returns [] when anatomy is unavailable (compose could * not be parsed) or when a published port resolves through a variable, since in * both cases the published set cannot be compared without risking a false flag. */ export function computeDocDrift( anatomy: AnatomyMarkdownInput | null, accessUrls: string, ): DocDriftFinding[] { if (!anatomy) return []; const { specs, indeterminate } = publishedPortModel(anatomy); if (indeterminate) return []; const seen = new Set(); const findings: DocDriftFinding[] = []; for (const line of accessUrls.split('\n')) { const port = extractExplicitAccessPort(line); if (port === null || isPublished(port, specs) || seen.has(port)) continue; seen.add(port); findings.push({ kind: 'access-url-port-unpublished', port, source: line.trim(), detail: `An access URL points to port ${port}, but no service in this stack publishes it. Review the access URL or the stack's published ports.`, }); } return findings.sort((a, b) => a.port - b.port || a.source.localeCompare(b.source)); }