feat: flag documentation drift in the stack dossier (#1349)

* 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.
This commit is contained in:
Anso
2026-06-10 13:59:10 -04:00
committed by GitHub
parent c2ac15b06a
commit 739bbf990e
6 changed files with 530 additions and 4 deletions
@@ -126,4 +126,67 @@ describe('StackDossierPanel', () => {
fireEvent.click(screen.getByTestId('dossier-download-btn'));
expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web'));
});
// Anatomy that publishes a single TCP host port, for documentation-drift tests.
const anatomyPublishing = (host: string): AnatomyMarkdownInput => ({
...anatomy,
ports: { web: [{ host, container: '80', proto: 'tcp' }] },
});
it('warns when an access URL names a port the stack does not publish', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' }));
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('32401')} canEdit />);
expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':32400');
});
it('does not warn when the access URL port is published', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' }));
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('32400')} canEdit />);
await screen.findByTestId('dossier-panel');
expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument();
});
it('shows the drift warning to read-only viewers', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' }));
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('32401')} canEdit={false} />);
expect(await screen.findByTestId('dossier-doc-drift')).toBeInTheDocument();
});
it('updates the warning live as access_urls is edited', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS }));
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('8080')} canEdit />);
await screen.findByTestId('dossier-panel');
expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument();
fireEvent.change(screen.getByTestId('dossier-field-access_urls'), { target: { value: 'http://host:9000' } });
expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':9000');
});
it('clears the warning when the URL is edited to a published port', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000' }));
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('8080')} canEdit />);
await screen.findByTestId('dossier-doc-drift');
fireEvent.change(screen.getByTestId('dossier-field-access_urls'), { target: { value: 'http://host:8080' } });
await waitFor(() => expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument());
});
it('renders one row per unpublished port', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000\nhttp://host:9001' }));
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('8080')} canEdit />);
const section = await screen.findByTestId('dossier-doc-drift');
expect(section).toHaveTextContent(':9000');
expect(section).toHaveTextContent(':9001');
});
it('suppresses the warning when a reload fails, never showing the previous stack stale', async () => {
vi.mocked(apiFetch)
.mockResolvedValueOnce(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000' }))
.mockResolvedValueOnce(jsonRes({ error: 'down' }, false));
const { rerender } = render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('8080')} canEdit />);
expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':9000');
// Switch stacks: the reload fails, so the prior stack's drifting fields stay
// resident but must not keep a warning on screen.
rerender(<StackDossierPanel stackName="web2" anatomy={anatomyPublishing('8080')} canEdit />);
await screen.findByTestId('dossier-retry-btn');
expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Copy, Download, Save } from 'lucide-react';
import { Copy, Download, Save, TriangleAlert } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
@@ -12,6 +12,7 @@ import {
type StackDossierFields,
} from '@/lib/dossierMarkdown';
import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
import { computeDocDrift, type DocDriftFinding } from '@/lib/docDrift';
import { useNodes } from '@/context/NodeContext';
interface StackDossierPanelProps {
@@ -124,9 +125,37 @@ function GeneratedFacts({ anatomy }: { anatomy: AnatomyMarkdownInput }) {
);
}
// Documentation drift: ports the operator documented in access_urls that the
// stack does not publish. Read-only and advisory; it never edits notes or
// compose. Visual language matches the Drift tab's warning findings.
function DocDriftWarnings({ findings }: { findings: DocDriftFinding[] }) {
return (
<section data-testid="dossier-doc-drift">
<div className={cn(LABEL_CLASS, 'mb-1.5')}>documentation drift</div>
<div className="rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-1">
{findings.map(f => (
<div key={f.port} className="border-t border-warning/20 py-2 first:border-t-0">
<div className="flex items-center gap-2">
<TriangleAlert className="h-3.5 w-3.5 shrink-0 text-warning" strokeWidth={1.5} />
<span className="rounded-md bg-warning/15 px-1.5 py-0.5 font-mono text-[11px] text-warning">:{f.port}</span>
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">not published</span>
</div>
<div className="mt-1 text-[12px] text-foreground/90">{f.detail}</div>
<div className="mt-1 truncate font-mono text-[11px] text-stat-subtitle" title={f.source}>{f.source}</div>
</div>
))}
</div>
</section>
);
}
export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
// Identifies the dossier currently in view. Doc-drift renders only once the
// load for *this* key has succeeded (see loadedKey), so a switch-in-flight or
// a failed load never diffs new anatomy against the prior stack's fields.
const currentKey = `${nodeId ?? ''}::${stackName}`;
const [fields, setFields] = useState<StackDossierFields>(EMPTY_DOSSIER_FIELDS);
// The last-saved values, kept in state so dirty-tracking compares against them
// without reading a ref during render.
@@ -134,6 +163,10 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
// The (node, stack) whose dossier has successfully loaded. Set only on a
// successful fetch, so it lags during a switch and stays behind on a failed
// load, which is exactly when doc-drift must stay hidden.
const [loadedKey, setLoadedKey] = useState<string | null>(null);
// Reload when the stack OR the active node changes: the same stack name can
// exist on two nodes with independent dossiers, and apiFetch scopes by the
@@ -151,10 +184,11 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
toast.error('Failed to load the stack dossier.');
return;
}
const loaded = pickFields(await res.json());
setServerFields(loaded);
setFields(loaded);
const next = pickFields(await res.json());
setServerFields(next);
setFields(next);
setLoadError(false);
setLoadedKey(`${nodeId ?? ''}::${stackName}`);
} catch {
if (!cancelled) {
setLoadError(true);
@@ -171,6 +205,13 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
[fields, serverFields],
);
// Recomputes live as the operator edits access_urls; depends on that single
// field so unrelated edits do not re-run the comparison.
const docDrift = useMemo(
() => computeDocDrift(anatomy, fields.access_urls),
[anatomy, fields.access_urls],
);
const setField = (key: keyof StackDossierFields, value: string) =>
setFields(prev => ({ ...prev, [key]: value }));
@@ -249,6 +290,8 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
)}
</section>
{loadedKey === currentKey && docDrift.length > 0 && <DocDriftWarnings findings={docDrift} />}
<section>
<div className={cn(LABEL_CLASS, 'mb-1.5')}>operator notes</div>
{loadError ? (
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import { computeDocDrift, extractExplicitAccessPort } from '../docDrift';
import type { AnatomyMarkdownInput, PortRow } from '../anatomyMarkdown';
function anatomyWith(ports: PortRow[]): AnatomyMarkdownInput {
return {
stackName: 'web',
services: ['web'],
ports: ports.length ? { web: ports } : {},
volumes: {},
restart: null,
envFile: null,
envVarCount: 0,
missingVars: [],
networkName: 'web_default',
gitSource: null,
};
}
const tcp = (host: string): PortRow => ({ host, container: '80', proto: 'tcp' });
const udp = (host: string): PortRow => ({ host, container: '80', proto: 'udp' });
/** Ports flagged for the given access_urls against the given published rows. */
const flagged = (urls: string, published: PortRow[] = []): number[] =>
computeDocDrift(anatomyWith(published), urls).map((f) => f.port);
describe('extractExplicitAccessPort', () => {
it('reads an explicit non-default port from an absolute URL', () => {
expect(extractExplicitAccessPort('http://host:8080')).toBe(8080);
expect(extractExplicitAccessPort('https://host:32400/web')).toBe(32400);
});
it('skips scheme-default ports (http :80, https :443) and port-less URLs', () => {
expect(extractExplicitAccessPort('http://host:80')).toBeNull();
expect(extractExplicitAccessPort('https://host:443')).toBeNull();
expect(extractExplicitAccessPort('https://host')).toBeNull();
});
it('isolates the port from userinfo and never reads a password', () => {
expect(extractExplicitAccessPort('http://user:pass@host:8080')).toBe(8080);
expect(extractExplicitAccessPort('http://user:pass@host')).toBeNull();
});
it('handles IPv6 in absolute and bare forms', () => {
expect(extractExplicitAccessPort('http://[::1]:8080')).toBe(8080);
expect(extractExplicitAccessPort('[::1]:8080')).toBe(8080);
});
it('parses bare host:port for host-ish authorities (the corrected fallback)', () => {
expect(extractExplicitAccessPort('plex.local:32400')).toBe(32400);
expect(extractExplicitAccessPort('plex.local:32400/web')).toBe(32400);
expect(extractExplicitAccessPort('localhost:8080')).toBe(8080);
expect(extractExplicitAccessPort('192.168.1.5:32400')).toBe(32400);
});
it('rejects prose that merely looks like host:port', () => {
expect(extractExplicitAccessPort('note:8080')).toBeNull(); // single-label host, not host-ish
expect(extractExplicitAccessPort('ratio 16:9')).toBeNull(); // space -> invalid URL
expect(extractExplicitAccessPort('see wiki')).toBeNull();
expect(extractExplicitAccessPort('')).toBeNull();
expect(extractExplicitAccessPort(' ')).toBeNull();
});
it('rejects out-of-range and non-numeric ports', () => {
expect(extractExplicitAccessPort('http://host:0')).toBeNull();
expect(extractExplicitAccessPort('http://host:99999')).toBeNull();
expect(extractExplicitAccessPort('http://host:abc')).toBeNull();
});
it('intentionally does not check a scheme-less single-label host, but does with a scheme', () => {
// `plex:32400` is indistinguishable from prose like `note:8080`, so the bare
// form is skipped to avoid false positives. Adding a scheme opts it in.
expect(extractExplicitAccessPort('plex:32400')).toBeNull();
expect(extractExplicitAccessPort('nas:8096')).toBeNull();
expect(extractExplicitAccessPort('http://plex:32400')).toBe(32400);
});
});
describe('computeDocDrift', () => {
it('flags a documented port that nothing publishes', () => {
expect(flagged('http://host:9000', [tcp('8080')])).toEqual([9000]);
});
it('does not flag a documented port that is published', () => {
expect(flagged('http://host:8080', [tcp('8080')])).toEqual([]);
});
it('does not flag a port-less or scheme-default URL', () => {
expect(flagged('http://host', [tcp('8080')])).toEqual([]);
expect(flagged('http://host:80', [tcp('8080')])).toEqual([]);
});
it('does not let a UDP-only publish satisfy an http access URL', () => {
expect(flagged('http://host:51820', [udp('51820')])).toEqual([51820]);
});
it('matches a documented port inside a published range', () => {
expect(flagged('http://host:8001', [tcp('8000-8002')])).toEqual([]);
expect(flagged('http://host:8003', [tcp('8000-8002')])).toEqual([8003]);
});
it('recognizes a one-part published port (Anatomy UI semantics)', () => {
expect(flagged('http://host:8096', [tcp('8096')])).toEqual([]);
});
it('dedupes repeated ports and sorts findings by port', () => {
expect(flagged('http://host:9000\nhttp://host:9000')).toEqual([9000]);
expect(flagged('http://host:9000\nhttp://host:8000', [tcp('1234')])).toEqual([8000, 9000]);
});
it('checks only URL-shaped lines, ignoring prose', () => {
expect(flagged('http://host:8080\nhttp://host:9000\nnote:8080\nsee wiki', [tcp('8080')])).toEqual([9000]);
});
it('flags only the unpublished line when published and unpublished URLs are mixed', () => {
expect(flagged('http://host:8080\nhttp://host:9000', [tcp('8080')])).toEqual([9000]);
});
it('treats an uppercase UDP proto as non-TCP', () => {
expect(flagged('http://host:51820', [{ host: '51820', container: '51820', proto: 'UDP' }])).toEqual([51820]);
});
it('stays quiet when a port is published through an unresolved variable', () => {
// ${PLEX_PORT}:32400 parses to a non-numeric host; its real value is unknown,
// so a documented :32400 must not be flagged as a false positive.
expect(flagged('http://host:32400', [{ host: '${PLEX_PORT}', container: '32400', proto: 'tcp' }])).toEqual([]);
});
it('suppresses the whole stack when a variable port is mixed with fixed ports', () => {
// One indeterminate (variable) port makes the published set unknowable, so
// even a port that is clearly unpublished stays unflagged. Pins the global
// suppression so it cannot regress to a partial check.
const published = [tcp('8080'), { host: '${PLEX_PORT}', container: '32400', proto: 'tcp' }];
expect(flagged('http://host:9999', published)).toEqual([]);
});
it('keeps the first source line when a port is deduped across lines', () => {
const [finding] = computeDocDrift(anatomyWith([]), 'http://a:9000\nhttps://b:9000/x');
expect(finding.source).toBe('http://a:9000');
});
it('returns nothing when anatomy is unavailable or no URLs are documented', () => {
expect(computeDocDrift(null, 'http://host:9000')).toEqual([]);
expect(flagged('', [tcp('8080')])).toEqual([]);
});
it('produces an actionable finding shape', () => {
const [finding] = computeDocDrift(anatomyWith([tcp('8080')]), ' http://host:9000 ');
expect(finding).toMatchObject({ kind: 'access-url-port-unpublished', port: 9000, source: 'http://host:9000' });
expect(finding.detail).toContain('9000');
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* 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));
}