/** * prompt-lint — a PURE, deterministic validator over a filmmaking-prompts * artifact. No I/O, no Date, no Math.random: the CLI handler reads the artifact * (from a project or a `--file`) and hands the parsed object here. * * Per Seedance packet it checks: * - 10-block order (text-driven packets only) via {@link checkSeedanceBlockOrder}; * - word count (warn outside the 280-600 words/packet window); * - brand / proper-name scrub leaks (reuses the prompt-rules scrubbers); * - the single-full-frame grid guard is present whenever a storyboard-grid * reference is attached (else the grid leaks as a moving split-screen); * - SUBJECT LOCK + CAPTURE REALISM + CAMERA CAPTURE present on every video packet; * - no Kelvin / hue° numeric-register tokens in a prose-register packet. * * Over the storyboard-grid panels (when the artifact carries a grid prompt): * - annotation strips (CAM/MOVE/MOOD) must read as 2-6 word uppercase slug * lines, not sentences (advisory); * - framing progression: vary wide -> medium -> close across the panels and * build to a climactic beat with closer framing in the final third * (advisory — the operator's panels are never rewritten). * * Over character descriptions (when the caller passes them): * - the ai-filmmaking identity word budget — 30-60 words is the target, * 61-100 warns, >100 is a hard failure (mirrors the generation-time check). * * Output is machine-readable: `{ packets: [{ sceneIndex, issues[] }], grid?, * characters?, ok }`, where `ok` is true iff no `error`-severity issue was * raised anywhere. The `grid` / `characters` sections are additive: absent * unless the artifact has a grid prompt / descriptions are provided. */ import { checkSeedanceBlockOrder, type FilmmakingPromptsArtifact, type FilmmakingSeedancePacket, type FilmmakingStoryboardPanel, } from './filmmaking-prompts.js'; import { SINGLE_FULL_FRAME_GUARD } from './seedance-blocks.js'; import { brandNeutralize, stripProperNames, type CastDescriptor } from './prompt-rules.js'; import { detectSlop } from './seedance-antislop.js'; import { analyzeReferenceTransfer } from './reference-transfer.js'; import { analyzeAllocation } from './allocation-model.js'; /** Words-per-packet advisory window. Outside this range raises a warning. */ export const PROMPT_LINT_MIN_WORDS = 280; export const PROMPT_LINT_MAX_WORDS = 600; /** Blocks that must appear on every video (Seedance) packet. */ const REQUIRED_VIDEO_BLOCKS = ['SUBJECT LOCK', 'CAPTURE REALISM', 'CAMERA CAPTURE'] as const; /** * Canonical tool-emitted blocks the anti-slop linter must never flag: the * toolchain legitimately emits "cinematic shot" and many constraint-slot * "no …" phrases inside its own guards. Stripped before slop scanning. */ const CANONICAL_EMITTED_BLOCKS: string[] = [SINGLE_FULL_FRAME_GUARD]; /** Max slop findings spelled out in the aggregated message before "(+N more)". */ const SLOP_FINDINGS_SHOWN = 6; /** Annotation strips (CAM/MOVE/MOOD) should read as 2-6 word slug lines. */ export const ANNOTATION_SLUG_MAX_WORDS = 6; /** ai-filmmaking identity description budget: 30-60 target, >100 is a failure. */ export const CHARACTER_DESCRIPTION_TARGET_MAX_WORDS = 60; export const CHARACTER_DESCRIPTION_FAIL_WORDS = 100; export type PromptLintIssueCode = | 'seedance-block-order' | 'word-count' | 'missing-required-block' | 'grid-guard-missing' | 'numeric-in-prose' | 'proper-name-leak' | 'brand-leak' | 'annotation-slug-format' | 'framing-progression' | 'character-description-budget' | 'slop' | 'reference-transfer' | 'allocation'; export interface PromptLintIssue { code: PromptLintIssueCode; severity: 'warning' | 'error'; message: string; } export interface PromptLintPacketResult { sceneIndex: number; issues: PromptLintIssue[]; } /** Storyboard-grid panel lint (annotation strips + framing progression). */ export interface PromptLintGridResult { issues: PromptLintIssue[]; } /** Per-character identity-description budget lint. */ export interface PromptLintCharacterResult { name: string; issues: PromptLintIssue[]; } export interface PromptLintResult { packets: PromptLintPacketResult[]; /** Present iff the artifact carries a storyboard-grid prompt. */ grid?: PromptLintGridResult; /** Present iff {@link PromptLintOptions.characterDescriptions} was provided. */ characters?: PromptLintCharacterResult[]; ok: boolean; } export interface PromptLintOptions { /** * Cinematography register the packets were rendered in. Default `'prose'` * (the Joey hard default): in prose register, Kelvin (`5200K`) and hue/angle * degree tokens (`40°`) are numeric-register leakage and get flagged. Pass * `'numeric'` to suppress that check when numeric tokens are intentional. */ register?: 'prose' | 'numeric'; /** * Cast name → visual-descriptor map. When provided, a packet whose text still * contains a proper name (i.e. {@link stripProperNames} would change it) is * flagged as a proper-name leak. Omitted → the proper-name check is skipped. */ cast?: CastDescriptor[]; /** * Brand tokens to scrub. When provided, a packet whose text still contains a * brand (i.e. {@link brandNeutralize} would change it) is flagged. Omitted → * the brand check is skipped. */ brands?: string[]; /** * Character name → stored identity description. When provided, each * description is checked against the ai-filmmaking word budget (30-60 target; * 61-100 warns; >100 errors, mirroring the generation-time check). Omitted → * the check is skipped and no `characters` section is emitted. */ characterDescriptions?: Array<{ name: string; description: string }>; } function wordCount(text: string): number { return text.split(/\s+/).filter(Boolean).length; } /** Lint a single packet, returning its ordered issue list. */ function lintPacket( packet: FilmmakingSeedancePacket, options: PromptLintOptions, ): PromptLintIssue[] { const issues: PromptLintIssue[] = []; const text = packet.promptText; // 1) Block order — only the text-driven packet follows the 10-block contract; // grid variants intentionally use the grid-reference body shape. if (packet.variant === 'text-driven') { const blockIssue = checkSeedanceBlockOrder(text); if (blockIssue) { issues.push({ code: 'seedance-block-order', severity: 'warning', message: blockIssue.message }); } } // 2) Word count window (advisory). const words = wordCount(text); if (words < PROMPT_LINT_MIN_WORDS || words > PROMPT_LINT_MAX_WORDS) { issues.push({ code: 'word-count', severity: 'warning', message: `packet is ${words} words; target ${PROMPT_LINT_MIN_WORDS}-${PROMPT_LINT_MAX_WORDS} words per packet.`, }); } // 3) Required video blocks must be present on every text-driven packet. Only // the text-driven variant follows the 10-block contract; the grid-reference // variants intentionally use the grid-reference body shape (which carries // the same identity discipline inline, not as labelled SUBJECT LOCK / etc. // blocks), so applying this check to them would be a false positive. if (packet.variant === 'text-driven') { for (const block of REQUIRED_VIDEO_BLOCKS) { if (!text.includes(block)) { issues.push({ code: 'missing-required-block', severity: 'error', message: `packet is missing the required "${block}" block.`, }); } } } // 4) Grid guard present whenever a storyboard-grid reference is attached. const hasGridRef = packet.references.some((reference) => reference.role === 'storyboard-grid'); if (hasGridRef && !text.includes(SINGLE_FULL_FRAME_GUARD)) { issues.push({ code: 'grid-guard-missing', severity: 'error', message: 'a storyboard-grid reference is attached but the single-full-frame guard is missing; the grid will leak as a moving split-screen.', }); } // 5) No Kelvin / hue° numeric-register tokens in a prose-register packet. if ((options.register ?? 'prose') === 'prose') { const kelvin = text.match(/\b\d+\s?K\b/g) ?? []; const degrees = text.match(/\d+°/g) ?? []; const tokens = [...kelvin, ...degrees]; if (tokens.length > 0) { issues.push({ code: 'numeric-in-prose', severity: 'error', message: `prose-register packet contains numeric-register tokens (${tokens.join(', ')}); use the prose register or pass register=numeric.`, }); } } // 6) Proper-name leak — the scrubbed text must equal the original. if (options.cast && options.cast.length > 0) { if (stripProperNames(text, options.cast) !== text) { issues.push({ code: 'proper-name-leak', severity: 'error', message: 'packet still contains a cast proper name; describe subjects by visual descriptor, never by name.', }); } } // 7) Brand leak — the brand-neutralised text must equal the original (modulo // whitespace collapse, which brandNeutralize always applies). if (options.brands && options.brands.length > 0) { const collapsed = text.replace(/\s{2,}/g, ' ').trim(); if (brandNeutralize(text, options.brands) !== collapsed) { issues.push({ code: 'brand-leak', severity: 'error', message: 'packet still contains a brand token; keep prompts brand-neutral.', }); } } // 8) Anti-slop advisory — empty hype language the model cannot act on. One // aggregated warning per packet; warning severity, so `ok` is unaffected. // Canonical tool-emitted blocks are ignored so the linter never flags its // own wording. const slop = detectSlop(text, { ignore: CANONICAL_EMITTED_BLOCKS }); if (slop.length > 0) { const shown = slop .slice(0, SLOP_FINDINGS_SHOWN) .map((finding) => `"${finding.match}" → ${finding.suggestion}`) .join('; '); const more = slop.length > SLOP_FINDINGS_SHOWN ? ` (+${slop.length - SLOP_FINDINGS_SHOWN} more)` : ''; issues.push({ code: 'slop', severity: 'warning', message: `slop language detected — replace with observable production detail: ${shown}${more}.`, }); } // 9) Reference-transfer contract — warn when a packet's references span ≥2 // bleed-relevant domains without a transfer/ignore clause (advisory). for (const advisory of analyzeReferenceTransfer( packet.references.map((reference) => ({ role: reference.role, label: reference.label })), text, )) { issues.push({ code: advisory.code, severity: advisory.severity, message: advisory.message }); } // 10) Allocation-model — warn when one packet over-allocates the budget (≥3 of // identity-detail / bold-motion / scene-density / readable-text). Advisory. for (const advisory of analyzeAllocation(text)) { issues.push({ code: advisory.code, severity: advisory.severity, message: advisory.message }); } return issues; } // --------------------------------------------------------------------------- // Storyboard-grid panel lint (ai-filmmaking annotation + framing discipline) // --------------------------------------------------------------------------- type FramingClass = 'wide' | 'medium' | 'close'; /** * Classify a CAM annotation string into a shot-size class. Close patterns are * checked first so "MEDIUM CLOSE" reads as close. Angle-only slugs ("LOW * ANGLE. PUSH IN", "DUTCH. SNAP ZOOM") carry no shot size and return null — * unclassifiable panels are simply ignored by the progression checks, so * free-text operator panels never produce false positives. */ export function classifyFraming(cam: string): FramingClass | null { const upper = cam.toUpperCase(); if (/(CLOSE|MACRO)/.test(upper)) return 'close'; if (/WIDE/.test(upper)) return 'wide'; if (/(MEDIUM|MID|OVER SHOULDER|OVER-THE-SHOULDER|OTS|PROFILE)/.test(upper)) return 'medium'; return null; } /** * Advisory lint over the grid panels. Never rewrites the operator's panels — * it only reports: * - annotation-slug-format: CAM/MOVE/MOOD strips longer than * {@link ANNOTATION_SLUG_MAX_WORDS} words or written as lowercase prose * (the strips should read like screenplay slug lines); * - framing-progression: missing wide/medium/close variety across the grid, * or no close framing in the final-third (climax) panels. * All issues are warning severity. Pure and deterministic. */ function lintStoryboardGridPanels(panels: FilmmakingStoryboardPanel[]): PromptLintIssue[] { const issues: PromptLintIssue[] = []; const longStrips: string[] = []; const proseStrips: string[] = []; for (const panel of panels) { const strips = [['CAM', panel.cam], ['MOVE', panel.move], ['MOOD', panel.mood]] as const; for (const [label, value] of strips) { if (wordCount(value) > ANNOTATION_SLUG_MAX_WORDS) { longStrips.push(`panel ${panel.panel} ${label}`); } const lower = (value.match(/[a-z]/g) ?? []).length; const upper = (value.match(/[A-Z]/g) ?? []).length; if (lower > upper) { proseStrips.push(`panel ${panel.panel} ${label}`); } } } if (longStrips.length > 0) { issues.push({ code: 'annotation-slug-format', severity: 'warning', message: `annotation strips exceed ${ANNOTATION_SLUG_MAX_WORDS} words (${longStrips.join(', ')}); strips should read as 2-6 word uppercase slug lines, not sentences.`, }); } if (proseStrips.length > 0) { issues.push({ code: 'annotation-slug-format', severity: 'warning', message: `annotation strips read as lowercase prose (${proseStrips.join(', ')}); strips should read as short uppercase slug lines.`, }); } const classes = panels.map((panel) => classifyFraming(panel.cam)); const classifiable = classes.filter((value): value is FramingClass => value !== null); if (classifiable.length >= 3) { const distinct = new Set(classifiable); if (distinct.size < 3) { const missing = (['wide', 'medium', 'close'] as const).filter((cls) => !distinct.has(cls)); issues.push({ code: 'framing-progression', severity: 'warning', message: `grid framing has no ${missing.join(' or ')} shots; vary the framing wide -> medium -> close across the panels instead of repeating one shot size.`, }); } } // Climax discipline: the final third of the grid should tighten — at least // one close framing among its classifiable panels. const finalThirdStart = Math.floor((panels.length * 2) / 3); const finalThird = classes.slice(finalThirdStart); if (finalThird.some((value) => value !== null) && !finalThird.includes('close')) { issues.push({ code: 'framing-progression', severity: 'warning', message: `no close framing in the climax panels (${finalThirdStart + 1}-${panels.length}); build to a climactic beat with closer framing in the final third.`, }); } return issues; } /** * ai-filmmaking identity-description word budget. Mirrors the generation-time * check in `buildCharacterSheetPrompts`: 30-60 words is the target, 61-100 * warns, >100 is an error ("over 100 is a failure"). */ function lintCharacterDescription(name: string, description: string): PromptLintIssue[] { const words = wordCount(description); if (words > CHARACTER_DESCRIPTION_FAIL_WORDS) { return [{ code: 'character-description-budget', severity: 'error', message: `${name} identity description is ${words} words; ai-filmmaking treats >100 as a failure (target 30-60). Trim scene effects/atmosphere down to identity-locking traits.`, }]; } if (words > CHARACTER_DESCRIPTION_TARGET_MAX_WORDS) { return [{ code: 'character-description-budget', severity: 'warning', message: `${name} identity description is ${words} words; ai-filmmaking target is 30-60 words.`, }]; } return []; } // --------------------------------------------------------------------------- // Video-prompt health checklist // --------------------------------------------------------------------------- export interface HealthCheckResult { criterion: string; pass: boolean; note?: string; } export interface ChecklistSummary { passed: number; total: 9; failures: string[]; } /** * Nine yes/no criteria that constitute the video-prompt health checklist * ("Promptlandia" criteria). Pure, deterministic, no network. * * Criterion definitions: * 1. Explicit subject — who or what is the primary on-screen subject. * 2. Explicit action — what is happening / the main motion or activity. * 3. Explicit scene/setting — where / environmental context. * 4. Camera angle — named viewpoint (wide, close-up, overhead, POV, etc.). * 5. Camera movement — named motion (dolly, pan, tilt, handheld, static, etc.). * 6. Lens/optical effects — focal length, depth-of-field, bokeh, flare, etc. * 7. Concrete style — specific, non-vague aesthetic descriptor. * 8. Temporal/sequence cues — shot timing, duration, cut markers, or ordinal labels. * 9. Audio spec — music, ambience, diegetic sound, or explicit silence. */ export function videoPromptHealthChecklist(prompt: string): HealthCheckResult[] { const lower = prompt.toLowerCase(); // 1) Explicit subject — any named entity, visual descriptor, or subject noun. // Heuristic: look for common subject introducers or concrete nouns. const subjectKeywords = [ 'subject', 'character', 'figure', 'person', 'man', 'woman', 'child', 'face', 'silhouette', 'protagonist', 'hero', 'dancer', 'musician', 'athlete', 'crowd', 'vehicle', 'car', 'product', 'object', 'animal', 'creature', 'robot', ]; const hasSubject = subjectKeywords.some((kw) => lower.includes(kw)); // 2) Explicit action — verb-based motion or activity descriptor. const actionKeywords = [ 'walk', 'run', 'move', 'jump', 'danc', 'sing', 'speak', 'reach', 'turn', 'cross', 'ride', 'drive', 'fly', 'fall', 'climb', 'swim', 'stand', 'sit', 'lean', 'throw', 'catch', 'fight', 'hold', 'carry', 'look', 'gaze', 'explod', 'collid', 'emerg', 'transform', 'dissolv', 'approach', 'depart', 'action', 'motion', 'activit', ]; const hasAction = actionKeywords.some((kw) => lower.includes(kw)); // 3) Explicit scene/setting — location, environment, or spatial context. const settingKeywords = [ 'scene', 'setting', 'location', 'environment', 'background', 'landscape', 'interior', 'exterior', 'indoor', 'outdoor', 'street', 'city', 'forest', 'desert', 'ocean', 'beach', 'mountain', 'room', 'studio', 'stage', 'field', 'corridor', 'courtyard', 'alley', 'roof', 'rooftop', 'plaza', 'market', 'train', 'highway', 'sky', 'space', 'underwater', 'architecture', 'world', ]; const hasSetting = settingKeywords.some((kw) => lower.includes(kw)); // 4) Camera angle — named viewing angle or shot size. const angleKeywords = [ 'wide', 'close-up', 'closeup', 'close up', 'medium shot', 'mid shot', 'overhead', 'bird\'s eye', 'birds eye', "bird's-eye", 'worm\'s eye', 'worms eye', 'eye.level', 'eye level', 'low angle', 'high angle', 'dutch angle', 'dutch tilt', 'over.the.shoulder', 'over the shoulder', 'pov', 'point of view', 'aerial', 'extreme close', 'establishing shot', 'two.shot', 'two shot', 'angle', ]; const hasAngle = angleKeywords.some((kw) => lower.includes(kw)); // 5) Camera movement — named camera motion. const movementKeywords = [ 'dolly', 'pan', 'tilt', 'zoom', 'pull', 'push', 'truck', 'pedestal', 'arc', 'handheld', 'hand.held', 'steadicam', 'tracking shot', 'tracking', 'crane', 'jib', 'gimbal', 'static', 'locked.off', 'locked off', 'orbiting', 'orbit', 'whip.pan', 'whip pan', 'rack focus', 'slow.motion', 'slow motion', 'timelapse', 'time.lapse', 'movement', 'camera move', 'camera motion', ]; const hasMovement = movementKeywords.some((kw) => lower.includes(kw)); // 6) Lens/optical effects — focal length, depth-of-field, bokeh, flare, lens type. const lensKeywords = [ 'bokeh', 'depth of field', 'depth.of.field', 'shallow dof', 'wide angle', 'telephoto', 'anamorphic', 'lens flare', 'lens.flare', 'fisheye', 'macro', 'tilt.shift', 'tilt shift', '35mm', '50mm', '85mm', '24mm', '16mm', '70mm', 'f/', 'aperture', 'focal length', 'prime lens', 'zoom lens', 'optical', 'vignett', 'chromatic aberration', 'distortion', 'film grain', 'grain', ]; const hasLens = lensKeywords.some((kw) => lower.includes(kw)); // 7) Concrete style — named visual style or aesthetic (non-vague). // Keywords like "cinematic" alone are too vague; we look for named styles or // genre descriptors that narrow down the look. const styleKeywords = [ 'noir', 'neon', 'cyberpunk', 'neo.noir', 'gothic', 'baroque', 'minimalist', 'hyperrealistic', 'photorealistic', 'impressionist', 'expressionist', 'documentary', 'commercial', 'editorial', 'fashion', 'horror', 'sci.fi', 'western', 'thriller', 'romance', 'action', 'indie', 'arthouse', 'blockbuster', 'moody', 'gritty', 'dreamy', 'surreal', 'vintage', 'retro', 'futuristic', 'warm tones', 'cool tones', 'muted palette', 'desaturated', 'vibrant', 'high.contrast', 'high contrast', 'low.key', 'high.key', 'golden hour', 'blue hour', 'magic hour', 'neon.lit', 'bleach bypass', 'technicolor', 'grindhouse', 'kodachrome', 'natural light', 'lifelike', 'anti.plastic', 'cinematic grounded', 'grounded realism', 'capture realism', ]; const hasStyle = styleKeywords.some((kw) => lower.includes(kw)); const styleNote = !hasStyle ? 'No concrete style keyword found; avoid vague terms like "cinematic" alone — name a genre, look, or aesthetic.' : undefined; // 8) Temporal/sequence cues — shot timing, duration markers, or ordinal labels. // Looks for timecode patterns (0:00, 00:00), "shot N", "scene N", duration words. const hasTimecode = /\d:\d{2}/.test(prompt) || /\d{2}:\d{2}/.test(prompt); const temporalKeywords = [ 'shot 1', 'shot 2', 'shot 3', 'shot 4', 'shot 5', 'shot 6', 'shot 7', 'shot 8', 'shot 9', 'first shot', 'second shot', 'opening shot', 'final shot', 'last frame', 'cut to', 'transition', 'sequence', 'beat', 'moment', 'duration', 'seconds', 'frame map', 'timeline', 'scene 1', 'scene 2', 'beginning', 'middle', 'end', 'intro', 'outro', 'act 1', 'act 2', 'act 3', 'open on', 'opens on', ]; const hasTemporal = hasTimecode || temporalKeywords.some((kw) => lower.includes(kw)); // 9) Audio spec — any music, ambient sound, diegetic audio, or explicit silence. const audioKeywords = [ 'sound', 'audio', 'music', 'score', 'soundtrack', 'ambien', 'ambient', 'diegetic', 'non.diegetic', 'silence', 'quiet', 'no music', 'sound bed', 'sfx', 'sound effect', 'voice', 'dialogue', 'narration', 'foley', 'beat', 'bass', 'melody', 'rhythm', 'tempo', 'audio spec', 'natural sound', ]; const hasAudio = audioKeywords.some((kw) => lower.includes(kw)); return [ { criterion: 'Explicit subject', pass: hasSubject, note: !hasSubject ? 'No clear subject found; name or describe the primary on-screen entity.' : undefined, }, { criterion: 'Explicit action', pass: hasAction, note: !hasAction ? 'No action verb found; describe what is happening or the main motion.' : undefined, }, { criterion: 'Explicit scene/setting', pass: hasSetting, note: !hasSetting ? 'No setting/location found; specify where the scene takes place.' : undefined, }, { criterion: 'Camera angle', pass: hasAngle, note: !hasAngle ? 'No camera angle found; name the shot size or viewpoint (wide, close-up, overhead, etc.).' : undefined, }, { criterion: 'Camera movement', pass: hasMovement, note: !hasMovement ? 'No camera movement found; specify motion (dolly, pan, static, handheld, etc.).' : undefined, }, { criterion: 'Lens/optical effects', pass: hasLens, note: !hasLens ? 'No lens or optical descriptor found; add focal length, bokeh, grain, or depth-of-field detail.' : undefined, }, { criterion: 'Concrete style (not vague)', pass: hasStyle, note: styleNote, }, { criterion: 'Temporal/sequence cues', pass: hasTemporal, note: !hasTemporal ? 'No temporal cues found; add timecodes, shot labels (Shot 1…), or duration beats.' : undefined, }, { criterion: 'Audio spec', pass: hasAudio, note: !hasAudio ? 'No audio specification found; add music, ambience, diegetic sound, or explicit silence.' : undefined, }, ]; } /** * Summarise the output of {@link videoPromptHealthChecklist}. * The `total` field is always 9 (the fixed number of criteria). */ export function summarizeChecklist(results: HealthCheckResult[]): ChecklistSummary { const passed = results.filter((r) => r.pass).length; const failures = results.filter((r) => !r.pass).map((r) => r.criterion); return { passed, total: 9, failures }; } // --------------------------------------------------------------------------- /** * Lint a filmmaking-prompts artifact: every Seedance packet, the storyboard-grid * panels (when a grid prompt is present), and the provided character identity * descriptions (when given). Pure: depends only on its arguments. */ export function lintFilmmakingPrompts( artifact: FilmmakingPromptsArtifact, options: PromptLintOptions = {}, ): PromptLintResult { const packets = artifact.seedancePackets.map((packet) => ({ sceneIndex: packet.sceneIndex, issues: lintPacket(packet, options), })); const grid = artifact.storyboardGridPrompt ? { issues: lintStoryboardGridPanels(artifact.storyboardGridPrompt.panels) } : undefined; const characters = options.characterDescriptions?.map((entry) => ({ name: entry.name, issues: lintCharacterDescription(entry.name, entry.description), })); const allIssues = [ ...packets.flatMap((packet) => packet.issues), ...(grid?.issues ?? []), ...(characters?.flatMap((entry) => entry.issues) ?? []), ]; const ok = allIssues.every((issue) => issue.severity !== 'error'); return { packets, ...(grid ? { grid } : {}), ...(characters ? { characters } : {}), ok, }; }