mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
739bbf990e
* feat: flag documentation drift in the stack dossier Warn in the Dossier tab when a port written into a stack's access_urls is not published by the stack's compose, so operator documentation stays aligned with what Sencho can observe. The check is deterministic, read-only, and frontend-only: it compares ports parsed from access_urls against the published ports the Anatomy panel already shows, never interprets prose, and stays quiet for port-less URLs, scheme-default ports (:80/:443), and ports published through a variable, to avoid false positives. Community tier, no gating. * test: pin doc-drift handling of bare hosts and mixed variable ports Make two deterministic-drift behaviors intentional and regression-proof after review: a scheme-less single-label host (plex:32400) is not checked, since it cannot be told apart from a plain note (add a scheme to opt in), and a stack mixing a variable-published port with fixed ports suppresses the whole check. Adds tests for both, a clarifying code comment, and a docs note with the http:// workaround. No behavior change.
148 lines
6.1 KiB
TypeScript
148 lines
6.1 KiB
TypeScript
/**
|
|
* 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<number>();
|
|
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));
|
|
}
|