import { readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { contentSizes, fileDigest } from '../native-ui/contract.mjs'; export const mediaSchema = 'documentation-media-capture/v1'; export const maintainedAssets = [ 'agent-providers.png', 'board-overview.png', 'board-to-workspace.gif', 'command-palette.png', 'maintenance-center.png', 'mobile-board.png', 'mobile-flow.gif', 'mobile-settings.png', 'mobile-task-workspace.png', 'notification-adapters.png', 'settings-navigation.png', 'squad-chat.png', 'task-workspace.png', 'workbench-panel.png', ]; export const taskModeAssets = ['drawer', 'expanded'].flatMap((presentation) => ['overview', 'plan', 'run', 'results', 'history'].map( (mode) => `task-${presentation}-${mode}.png` ) ); export const focusedBoardAssets = ['board-5000.png']; const digest = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); const text = (value) => typeof value === 'string' && value.trim().length > 0; // Decode the ASCII URL characters that HTML/JSON metadata can spell without // changing the destination. Non-ASCII entities cannot spell maintained names. function decodeDestinations(content) { const entities = { amp: '&', quot: '"', apos: "'", sol: '/', bsol: '\\', period: '.', percnt: '%', colon: ':', quest: '?', num: '#', equals: '=', }; return content .replace(/\\u([a-f0-9]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) .replaceAll('\\/', '/') .replace(/&(#x[a-f0-9]+|#\d+|[a-z]+);/gi, (original, entity) => { if (!entity.startsWith('#')) return entities[entity] ?? original; const hex = entity[1].toLowerCase() === 'x'; const code = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10); return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : original; }); } // This is provenance/freshness validation, not a claim that the pixels or GIF // semantics have been reviewed. The capture and playback checks remain separate. export function mediaEvidenceFailures(report, expected, now = Date.now()) { if ( !/^[a-f0-9]{40}$/.test(expected?.commit ?? '') || !digest(expected?.packageDigest) || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(expected?.version ?? '') ) return ['invalid expected documentation candidate identity']; if (report?.schema !== mediaSchema) return ['missing or unknown documentation media schema']; const errors = []; if (report.status !== 'captured') errors.push('documentation capture did not finish'); if (report.commit !== expected.commit) errors.push('stale documentation candidate commit'); if (report.version !== expected.version) errors.push('stale documentation version'); if (report.packageDigest !== expected.packageDigest) errors.push('stale documentation candidate package'); const completed = Date.parse(report.completedAt); if (!Number.isFinite(completed) || completed > now) errors.push('missing or future documentation completion time'); const assets = Array.isArray(report.assets) ? report.assets : []; if ( assets.length !== maintainedAssets.length || new Set(assets.map((asset) => asset?.name)).size !== maintainedAssets.length ) errors.push('missing or duplicate maintained media decisions'); const supplemental = report.taskModeAssets ?? []; const focused = report.focusedBoardAssets ?? []; if (!Array.isArray(focused)) return [...errors, 'invalid focused board capture list']; if ( report.focusedBoardAssets !== undefined && (focused.length !== 1 || focused[0]?.name !== focusedBoardAssets[0]) ) errors.push('missing or duplicate focused board capture'); if (!Array.isArray(supplemental)) return [...errors, 'invalid task mode capture list']; if ( report.taskModeAssets !== undefined && (supplemental.length !== taskModeAssets.length || new Set(supplemental.map((asset) => asset?.name)).size !== taskModeAssets.length) ) errors.push('missing or duplicate task mode captures'); const expectedNames = [ ...maintainedAssets, ...(report.taskModeAssets === undefined ? [] : taskModeAssets), ...(report.focusedBoardAssets === undefined ? [] : focusedBoardAssets), ]; for (const name of expectedNames) { const asset = [...assets, ...supplemental, ...focused].find((item) => item?.name === name); if (!asset || !['keep', 'replace', 'retire'].includes(asset.decision) || !text(asset.reason)) { errors.push(`${name}: missing explicit media decision and reason`); continue; } if (asset.decision === 'retire') { if ( name.endsWith('.gif') || taskModeAssets.includes(name) || focusedBoardAssets.includes(name) ) errors.push(`${name}: named interaction GIF must remain maintained`); if (asset.path !== undefined || asset.sha256 !== undefined || asset.capture !== undefined) errors.push(`${name}: retired media must not claim a current capture`); continue; } if (asset.path !== `docs/assets/v${expected.version}/${name}`) errors.push(`${name}: capture is outside the candidate versioned media directory`); if (!digest(asset.sha256)) errors.push(`${name}: missing asset digest`); const capture = asset.capture; if (capture?.commit !== expected.commit || capture?.version !== expected.version) errors.push(`${name}: stale capture identity`); if (capture?.packageDigest !== expected.packageDigest) errors.push(`${name}: capture is not bound to the candidate package`); const boundary = name.startsWith('mobile-') ? 'mobile-browser' : 'packaged-macos'; if (capture?.boundary !== boundary) errors.push(`${name}: wrong capture boundary`); if (boundary === 'packaged-macos' && capture?.packaged !== true) errors.push(`${name}: desktop capture did not use the packaged application`); const viewport = focusedBoardAssets.includes(name) ? { width: 1360, height: 900 } : boundary === 'mobile-browser' ? { width: 390, height: 844 } : contentSizes.normal; const taskMode = taskModeAssets.includes(name); if ( taskMode && (capture?.nativeWindow?.bounds?.width !== 1180 || capture?.nativeWindow?.bounds?.height !== 900 || capture?.width !== 1180 || capture?.height < 760 || capture?.height > 900 || capture?.nativeWindow?.contentBounds?.width !== capture?.width || capture?.nativeWindow?.contentBounds?.height !== capture?.height || capture?.framing !== 'native-content-without-window-frame') ) errors.push(`${name}: missing matched task window geometry or explicit framing`); if (!taskMode && (capture?.width !== viewport.width || capture?.height !== viewport.height)) errors.push( `${name}: wrong supported ${boundary === 'mobile-browser' ? 'mobile' : 'packaged macOS'} viewport` ); if ( ![capture?.width, capture?.height, capture?.scaleFactor].every( (n) => Number.isFinite(n) && n > 0 ) ) errors.push(`${name}: missing capture dimensions or scale`); const capturedAt = Date.parse(capture?.capturedAt); if (!Number.isFinite(capturedAt) || capturedAt > completed || capturedAt > now) errors.push(`${name}: invalid capture time`); if (name.endsWith('.gif') && capture?.method !== 'interaction-recording') errors.push(`${name}: still-image montage is not an interaction recording`); if (name.endsWith('.png') && capture?.method !== 'window-capture') errors.push(`${name}: missing window capture provenance`); } return errors; } // Inspect tracked maintained text, including the docs index/metadata. Historical // release sources keep their historical links; ordinary guides may not do so. export function staleMediaReferences(contents, report, version) { const errors = []; const retired = new Set( (report.assets ?? []).filter((a) => a?.decision === 'retire').map((a) => a.name) ); for (const [file, content] of contents) { if (file.startsWith('docs/releases/')) continue; if (file !== 'README.md' && !file.startsWith('docs/')) continue; // Tokenize destinations in Markdown, HTML, CSS, and JSON metadata. URL // resolution must precede version checks: dot segments and encoded names // are legitimate links, not permission to bypass the stale-reference gate. for (const token of decodeDestinations(content).matchAll(/[^\s"'<>()[\]{}=]+/g)) { let pathname; try { const url = new URL(token[0], `https://documentation.invalid/${file}`); pathname = path.posix.normalize(decodeURIComponent(url.pathname)); } catch { continue; // Malformed URLs belong to the documentation link checker. } const name = path.posix.basename(pathname); if (!maintainedAssets.includes(name)) continue; if (!pathname.endsWith(`/assets/v${version}/${name}`)) errors.push(`${file}: stale maintained reference ${token[0]}`); if (retired.has(name)) errors.push(`${file}: retired media remains referenced: ${name}`); } } return errors; } export async function verifyMediaEvidence({ evidencePath, root, expected, maintainedContents }) { if (!Array.isArray(maintainedContents) || maintainedContents.length === 0) return ['missing maintained documentation reference inventory']; const report = JSON.parse(await readFile(evidencePath, 'utf8')); if (report.mode !== 'capture' || report.dirty !== false) return ['documentation media requires an original clean capture']; const errors = mediaEvidenceFailures(report, expected); if (errors.length) return errors; root = await realpath(root); const intendedDirectory = path.join(root, `docs/assets/v${expected.version}`); let mediaDirectory; try { mediaDirectory = await realpath(intendedDirectory); if (mediaDirectory !== intendedDirectory) return ['versioned media directory traverses a symlink']; } catch (error) { return [`missing candidate media directory: ${error.message}`]; } for (const asset of [ ...report.assets, ...(report.taskModeAssets ?? []), ...(report.focusedBoardAssets ?? []), ]) { if (asset.decision === 'retire') continue; try { const intendedFile = path.join(root, asset.path); const file = await realpath(intendedFile); if (file !== intendedFile || path.dirname(file) !== mediaDirectory) throw new Error('asset escaped versioned media directory or traverses a symlink'); if ((await fileDigest(file)) !== asset.sha256) throw new Error('stale or changed media bytes'); } catch (error) { errors.push(`${asset.name}: ${error.message}`); } } errors.push(...staleMediaReferences(maintainedContents, report, expected.version)); return errors; }