From 75d354fb2180325e0dd5277219ee1f3dc43f3ffd Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 14:56:27 +0000 Subject: [PATCH] import shared pure modules from server/lib instead of copying them into client/src/lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same need — one pure module used by both runtimes — was served three ways: 25 declared byte-for-byte "server mirror" copies with 20 parity tests policing them, a growing set of files that just import the server leaf (NAV_COMMANDS since the server loaded client source. The copies drifted anyway (#6303 accepted `https:foo` on one side only), and per #6363 their parity tests were unreachable from a scoped PR, so the drift reached main. Settle on one direction: the client imports pure server/lib leaves, the server never imports client source. - Convert all 25 declared mirrors in client/src/lib into named re-exports of the server leaf. Client-only extras stay (INGEST_OPTIONS, appendTriggerWords, sceneShotWarnings, the shot-grammar labels, clampInt/summarizeLengthProfile). - catalogTypes and slashdoCatalog become projections rather than re-exports: the client registry carries UI-only editor layout / button tones, so it decorates the server list instead of restating it. The hand-copied BIBLE_LIMITS caps in editableListFields are gone. - Split three leaves so the client can import them at all: bibleLimits.js out of storyBible.js (crypto + fileUtils), portosUrls.js out of ports.js (module-scope process.env), youtubeUrlAssert.js out of youtubeUrl.js (ServerError → events). - Flip both reverse edges: personaTraitBlend inlines its own clamp, and avatarStyles moves to server/lib with a client re-export. - Delete the 20 parity tests whose only assertion was declaration equality, plus mirrorParity's README-driven coverage guard. Behaviour those tests uniquely covered moves to plain unit tests (isSafeHref's scheme matrix, new youtubeUrl/tribeCadence suites, the lora_effect_probe.py version lockstep, and a client loraEffect suite for the badge table). - Add two always-run guards: scripts/client-server-import-purity.test.js walks the import graph of every server/lib module the client imports and fails on a Node built-in or an out-of-tree import; scripts/server-imports-no-client.test.js fails on any server module importing client source, with the 17 remaining test-side cross-imports frozen in a list that may only shrink. The import budget rises 89,425 → 89,639: the four new leaves are extra nodes on paths that already existed, not new eager edges into a heavy subtree. Closes #6364 --- client/src/lib/README.md | 72 ++--- client/src/lib/appIdentity.js | 20 +- client/src/lib/assetProvenance.js | 217 ++------------- client/src/lib/avatarStyles.js | 48 +--- client/src/lib/bareUrl.js | 79 +----- client/src/lib/bibleLimits.js | 120 +------- client/src/lib/canonPrompt.js | 219 +-------------- client/src/lib/catalogTypes.js | 256 ++++-------------- client/src/lib/extensionErrors.js | 50 +--- client/src/lib/goalFeatureMap.js | 81 +----- client/src/lib/grokVideoClip.js | 19 +- client/src/lib/isSafeHref.js | 27 +- client/src/lib/isSafeHref.mirror.test.js | 26 -- client/src/lib/issueLength.js | 59 ++-- client/src/lib/letteringDensity.js | 205 ++------------ client/src/lib/loraEffect.js | 34 +-- client/src/lib/loraEffect.test.js | 91 +++++++ client/src/lib/loraTriggers.js | 60 +--- client/src/lib/musicDuration.js | 95 +------ client/src/lib/personaTraitBlend.js | 242 ++--------------- client/src/lib/ports.js | 32 +-- client/src/lib/ports.parity.test.js | 32 --- client/src/lib/postRotation.js | 91 +------ client/src/lib/reactorVideoClip.js | 96 ++----- client/src/lib/repoUrl.js | 217 +-------------- client/src/lib/scenePrompt.js | 197 ++------------ client/src/lib/seasonStructure.js | 46 +--- client/src/lib/shotContinuity.js | 100 +------ client/src/lib/shotGrammar.js | 34 +-- client/src/lib/slashdoCatalog.js | 122 +++------ client/src/lib/textUtils.js | 29 +- client/src/lib/tribeCadence.contract.test.js | 88 ------ client/src/lib/tribeCadence.js | 49 +--- client/src/lib/videoReferenceModes.js | 156 ++--------- client/src/lib/youtubeUrl.js | 38 +-- client/src/pages/Loras.jsx | 8 +- docs/PORTS.md | 2 +- scripts/ci-test-plan.js | 12 +- scripts/ci-test-plan.test.js | 32 +-- scripts/client-server-import-purity.test.js | 128 +++++++++ scripts/repo-scan-guards.test.js | 6 +- scripts/server-imports-no-client.test.js | 120 ++++++++ server/lib/README.md | 26 +- server/lib/appIdentity.mirror.test.js | 9 - server/lib/assetProvenance.test.js | 12 - server/lib/avatarStyles.js | 41 +++ server/lib/bareUrl.js | 6 +- server/lib/bareUrl.mirror.test.js | 55 ---- server/lib/bibleLimits.js | 172 ++++++++++++ server/lib/canonPrompt.js | 8 +- server/lib/canonPrompt.mirror.test.js | 84 ------ server/lib/catalogTypes.js | 30 +- server/lib/catalogTypes.parity.test.js | 60 ---- server/lib/editorial/letteringDensity.js | 2 +- server/lib/extensionErrors.js | 4 +- server/lib/extensionErrors.mirror.test.js | 48 ---- server/lib/goalFeatureMap.js | 6 +- server/lib/importScoping.test.js | 7 + server/lib/index.js | 5 + server/lib/isSafeHref.js | 6 +- server/lib/isSafeHref.test.js | 11 + server/lib/issueLength.mirror.test.js | 55 ---- server/lib/loraEffect.parity.test.js | 123 --------- server/lib/loraEffect.test.js | 28 ++ server/lib/loraTriggers.parity.test.js | 120 -------- server/lib/mirrorCoverage.test.js | 188 ------------- server/lib/mirrorParity.js | 12 +- server/lib/musicDuration.js | 3 +- server/lib/musicDuration.mirror.test.js | 44 --- server/lib/personaTraitBlend.js | 11 +- server/lib/personaTraitBlend.parity.test.js | 31 --- server/lib/portosUrls.js | 12 + server/lib/ports.js | 18 +- server/lib/ports.test.js | 6 +- server/lib/postRotation.js | 8 +- server/lib/postRotation.mirror.test.js | 38 --- server/lib/repoUrl.js | 5 +- server/lib/repoUrl.mirror.test.js | 61 ----- server/lib/scenePrompt.test.js | 38 +-- server/lib/seasonStructure.mirror.test.js | 23 -- server/lib/shotGrammar.mirror.test.js | 22 -- server/lib/slashdoCatalog.js | 2 +- server/lib/storyBible.js | 166 +----------- server/lib/textUtils.js | 4 +- server/lib/textUtils.test.js | 46 +--- server/lib/tribeCadence.js | 7 +- server/lib/tribeCadence.test.js | 62 +++++ server/lib/videoReferenceModes.js | 6 +- server/lib/videoReferenceModes.mirror.test.js | 63 ----- server/lib/youtubeUrl.js | 23 +- server/lib/youtubeUrl.mirror.test.js | 54 ---- server/lib/youtubeUrl.test.js | 70 +++++ server/lib/youtubeUrlAssert.js | 23 ++ server/routes/cosStatusRoutes.js | 7 +- server/routes/cosStatusRoutesAvatar.test.js | 2 +- server/services/creativeDirectorPrompts.js | 2 +- server/services/taskPromptDefaults.test.js | 2 +- .../taskPromptDefaults/integrityHash.js | 2 +- .../taskPromptDefaults/previousDefaults.js | 2 +- server/services/taskPromptDefaults/prompts.js | 2 +- server/services/taskSchedule.test.js | 2 +- server/services/trackYoutubeImport.js | 3 +- server/services/youtubeIngest.js | 3 +- 103 files changed, 1374 insertions(+), 4272 deletions(-) delete mode 100644 client/src/lib/isSafeHref.mirror.test.js create mode 100644 client/src/lib/loraEffect.test.js delete mode 100644 client/src/lib/ports.parity.test.js delete mode 100644 client/src/lib/tribeCadence.contract.test.js create mode 100644 scripts/client-server-import-purity.test.js create mode 100644 scripts/server-imports-no-client.test.js delete mode 100644 server/lib/appIdentity.mirror.test.js create mode 100644 server/lib/avatarStyles.js delete mode 100644 server/lib/bareUrl.mirror.test.js create mode 100644 server/lib/bibleLimits.js delete mode 100644 server/lib/canonPrompt.mirror.test.js delete mode 100644 server/lib/catalogTypes.parity.test.js delete mode 100644 server/lib/extensionErrors.mirror.test.js delete mode 100644 server/lib/issueLength.mirror.test.js delete mode 100644 server/lib/loraEffect.parity.test.js delete mode 100644 server/lib/loraTriggers.parity.test.js delete mode 100644 server/lib/mirrorCoverage.test.js delete mode 100644 server/lib/musicDuration.mirror.test.js delete mode 100644 server/lib/personaTraitBlend.parity.test.js create mode 100644 server/lib/portosUrls.js delete mode 100644 server/lib/postRotation.mirror.test.js delete mode 100644 server/lib/repoUrl.mirror.test.js delete mode 100644 server/lib/seasonStructure.mirror.test.js delete mode 100644 server/lib/shotGrammar.mirror.test.js create mode 100644 server/lib/tribeCadence.test.js delete mode 100644 server/lib/videoReferenceModes.mirror.test.js delete mode 100644 server/lib/youtubeUrl.mirror.test.js create mode 100644 server/lib/youtubeUrl.test.js create mode 100644 server/lib/youtubeUrlAssert.js diff --git a/client/src/lib/README.md b/client/src/lib/README.md index 6136ffbf28..147b1d5697 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -7,9 +7,15 @@ extend it. When you add a new module, add it to `index.js` AND add a row here. Hooks (state + lifecycle) live in `client/src/hooks/`. HTTP/socket clients live in `client/src/services/`. Pure formatting helpers live in `client/src/utils/`. -Several modules here are **server mirrors** — they must be kept byte-for-byte in sync with -their server counterpart. The server copy is authoritative; the matching server test file -is the contract. +**One pure module, one definition.** When the browser and the server need the same pure +logic, the client imports the `server/lib` leaf — `export { a, b } from '../../../server/lib/x.js'` +— it does not copy it. Import the LEAF, never `server/lib/index.js`, and never the other +direction: nothing under `server/` may import from `client/`, because a client-only +dependency added to such a file breaks the server CI job. A server module the client +imports must reach no Node built-in and nothing outside `server/lib`; when the natural +home does (`storyBible.js` pulls `crypto`), split the pure part into its own leaf. +`server/lib/importGraphPurity.test.js` and `server/serverImportsNoClient.test.js` enforce +both halves. ## Discovery rule @@ -21,47 +27,47 @@ grep -i "what you want to do" client/src/lib/README.md | `eidoverseFrame.js` | Versioned hosted Eidoverse message guards, exact section navigation allowlist, and browser label preferences. | | `eidoverseWorldReset.js` | Client reset-reconciliation maps for Eidoverse source kinds and district asset slots; parity-tested against the authoritative server world-design contracts. | | `postQuickSession.js` | Pure Quick POST duration presets, local-observation estimator, deterministic budget composer, and preview metadata. | -| `postRotation.js` | Pure deterministic day-based rotation for POST practice selection — `orderByRecencyRotation` sorts candidates fresh-before-recently-practiced, then by priority, and rotates equivalent ones by local day. Mirrored from `server/lib/postRotation.js`. | +| `postRotation.js` | Re-export of `server/lib/postRotation.js` — `orderByRecencyRotation` sorts POST practice candidates fresh-before-recently-practiced, then by priority, rotating equivalent ones by local day. | --- -## Prompt & rendering (server mirrors) +## Prompt & rendering | Module | Purpose | |---|---| -| `canonPrompt.js` | Mirror of `server/lib/canonPrompt.js`. SHORT/RICH/PREVIEW spec + `flattenCanonDescriptorFragments` / `mapCanonDescriptorFragments` / `descriptorForCanonEntry`. | -| `scenePrompt.js` | Mirror of `server/lib/scenePrompt.js`. Scene-prompt composer + bible matchers. | +| `canonPrompt.js` | Re-export of `server/lib/canonPrompt.js`. SHORT/RICH/PREVIEW spec + `flattenCanonDescriptorFragments` / `mapCanonDescriptorFragments` / `descriptorForCanonEntry`. Named exports only, so the server-only `flatten*` helpers stay out of the client barrel. | +| `scenePrompt.js` | Re-export of `server/lib/scenePrompt.js`. Scene-prompt composer + bible matchers. | | `composeStyledPrompt.js` | Compose user prompt + negative with an optional style preset. `composeCanonStyledPrompt` builds the `": "` + universe-preset render the canon section and characters step share. | | `cleanPlatePrompt.js` | Clean-plate prompt builder for setting canon entries (Cluster A — A4). | -| `personaTraitBlend.js` | Mirror of `server/lib/personaTraitBlend.js`. Digital-twin persona trait-blending (M34 P7) — `describeTraitAdjustments` / `renderTraitBlendDirective` / `BIG_FIVE_LEAN` for the Personas UI preview. | -| `seasonStructure.js` | Mirror of `server/lib/seasonStructure.js`. | +| `personaTraitBlend.js` | Re-export of `server/lib/personaTraitBlend.js`. Digital-twin persona trait-blending (M34 P7) — `describeTraitAdjustments` / `renderTraitBlendDirective` / `BIG_FIVE_LEAN` for the Personas UI preview. | +| `seasonStructure.js` | Re-export of `server/lib/seasonStructure.js`. | | `sheetPointers.js` | Mirror of the character-sheet pointer helpers from `server/lib/storyBible.js`. `LEGACY_SHEET_VARIANT_ID` + `readSheetPointer` / `listSheetPointers` / `applySheetPointer` for traversing both the legacy `referenceSheetImageRef` field and the `referenceSheets` map. | -| `shotContinuity.js` | Client mirror of `server/lib/editorial/shotContinuity.js` (#1315). `findAxisReversals` / `findShotTypeMonotony` (kept byte-for-byte with the authoritative server check) plus the client-only `sceneShotWarnings(scene, opts)` composer — the storyboards / episode-video stages' inline pre-render warnings (#1468) for 180°-rule axis jumps and shot-type monotony, so the user sees them before spending render time. Pure/DOM-free. | -| `shotGrammar.js` | Client mirror of the controlled vocabularies in `server/lib/shotGrammar.js` (#1315): `SHOT_TYPES` / `SCREEN_DIRECTIONS` enums (in sync with the server so a hand-set value passes `storyboardShotSchema`) + `SHOT_TYPE_LABELS` / `SCREEN_DIRECTION_LABELS` for the storyboards shot-grammar editor selects (#1468). | +| `shotContinuity.js` | The storyboards / episode-video stages' INLINE pre-render continuity warnings (#1468). `findAxisReversals` / `findShotTypeMonotony` are re-exported from `server/lib/editorial/shotContinuity.js`, the same detectors the authoritative `visual.shot-continuity` check runs; `sceneShotWarnings(scene, opts)` is the client-only composer (the server emits findings in a different shape). Pure/DOM-free. | +| `shotGrammar.js` | Storyboard shot-grammar display labels. `SHOT_TYPES` / `SCREEN_DIRECTIONS` are re-exported from `server/lib/shotGrammar.js`, so a hand-set value in the editor always passes `storyboardShotSchema`; `SHOT_TYPE_LABELS` / `SCREEN_DIRECTION_LABELS` are the client-only selects (#1468). | | `universeStylePreset.js` | Build the client-side style preset that `composeStyledPrompt` layers on top. | | `universeRunTag.js` | `buildUniverseSectionRenderTag(universe, kindKey, entry)` — the durable `universeRun` job tag the canon-render call sites (UniverseCanonSection, NounsStage, Story Builder characters step) pass to `generateImage` so the server auto-files the render into the universe collection AND appends it to the entry's `imageRefs[]` (no client follow-up PATCH). | | `autopilotMilestones.js` | Series-Autopilot milestone map. `buildAutopilotMilestones(plan, progress, {terminal})` folds the run's projected plan (the `start` frame) and its live progress snapshot (`progress` frames / the status route) into ordered rows with a `MILESTONE_STATUS` each — a furthest-index cursor, so a gate the run revisits can't un-finish the milestones after it; `summarizeAutopilotMilestones(rows)` rolls them into the header meter (a settled-but-unstepped milestone counts as complete); `describeAutopilotVerification(kind, verification)` renders what a gate actually validated — shared with the panel's `frameLabel`, since the milestone row and the activity log render the same telemetry; `isStoppedTerminal(terminal)` is the one definition of "the run stopped mid-plan"; `autopilotMarkerTerminal(status)` translates a persisted `autopilot.status` into the terminal frame type the fold reads, so a map rebuilt from the marker after a reload (#4140) flags the step a paused run stopped on. Also owns `AUTOPILOT_STEP_LABELS` / `autopilotStepLabel(kind)`, the one set of human labels for conductor step kinds (shared by the map and the panel's live status line). Used by `components/pipeline/AutopilotMilestones.jsx` + `AutopilotPanel.jsx`. | | `beatColors.js` | `BEAT_KIND_COLORS` + `getBeatKindColor(kind)` — per-kind display colors for reader-map emotional beats (kinds defined server-side in `storyArc.js`). Keeps every beat visualization consistent. | | `beatGrid.js` | Music-video beat-quantized timeline arranger (#1854). `buildBeatGridPoints(audioAnalysis)` merges beats/downbeats/section edges into one ranked snap-point list; `snapTimeToGrid(timeSec, gridPoints, toleranceSec)` finds the nearest point within tolerance; `computeSceneSpans(scenes, durationSec)` lays out scenes without a persisted `startSec`/`endSec` contiguously as a display-only fallback; `computeDragSpan({kind, startSpan, deltaSec, gridPoints, ...})` resolves a timeline drag gesture (`'move'` or `'right'` — no `'left'`, since the render can only trim from a clip's own frame 0) into a new span; `shouldMarkBeatAligned({kind, snapped, wasPersisted})` gates the `beatAligned` flag so a reposition-only drag can't silently promote an unpersisted scene's placeholder fallback duration into a "saved exactly" render duration; `autoArrangeScenes(scenes, audioAnalysis)` proposes a full `{ sceneId, startSec, endSec, beatAligned }[]` arrangement by distributing scenes across song sections weighted by each section's `energy` (#1915). Used by `components/musicVideo/BeatTimeline.jsx` and `pages/MusicVideo.jsx`. | -| `bibleLimits.js` | Mirror of `server/lib/storyBible.js` `BIBLE_LIMITS`, plus `capImageRefs` / `appendImageRefById` for the optimistic imageRefs-append paths. | -| `catalogTypes.js` | Client mirror of `server/lib/catalogTypes.js` — catalog ingredient type registry (label, badge color, primary-content key/label, snippet fallback chain, per-type editor field list) for the Catalog list/picker/editor. | +| `bibleLimits.js` | `BIBLE_LIMITS` re-exported from the pure leaf `server/lib/bibleLimits.js`, plus the client-only `capImageRefs` / `appendImageRefById` for the optimistic imageRefs-append paths. | +| `catalogTypes.js` | The catalog ingredient type registry as the UI consumes it. `CATALOG_TYPES` is PROJECTED from `server/lib/catalogTypes.js` (label, badge color, primary-content key/label, snippet fallback chain, `editableListFields` and its `BIBLE_LIMITS` caps) and decorated with the client-only editor layout (`editorSections` / `editorFields`); `RELATION_KINDS`, `MEDIA_KINDS`, `canonicalTagKey`, `payloadSnippet`, `USER_TYPE_FIELD_KINDS` are re-exported outright. Also the badge/ref-role lookups and the user-defined-type normalization `useCatalogTypes` merges in. | | `creativeDirectorPlan.js` | Pure presentation helpers for the CD studio Plan board (CDO Phase 4) — annotate `plan.steps[]` with tool cost-class/timing/approval flags, cost/status summaries, owning-surface deep links, status/cost badge tone maps. | | `creativeDirectorPreview.js` | `selectProjectPreview(project)` → `{ kind: 'video'\|'image'\|'audio'\|'none', jobId?, src?, poster?, label, durationSec? }` — picks a CD project's representative produced asset (finalVideoId → last rendered scene → last done plan video/image step → `musicBed` audio → startingImageFile) for the list cards + Overview tab (#2702, audio #2772). Pure, computed off the already-returned project payload (no fetch). Plus `previewAspectClass(aspectRatio)` (literal Tailwind classes — a computed `aspect-[w/h]` never lands in the build), `startingImageSrc()` (client mirror of `localImageFilename`), and the `videoSrcForJob`/`videoPosterForJob`/`imageSrcForJob`/`musicBedSrc` path builders — the one client-side definition of the ``-named asset convention, shared with `ScenePreview.jsx`. | | `editorialRoadmap.js` | `projectAnalyzedPoints` (aggregate roadmap → analyzed chart points with arc-position `frac`) + `dominant` (most-frequent string). Shared by EditorialRoadmapPanel and the Reader Map page. | | `federatedMediaReadiness.js` | One reading of a peer’s federated media-provider readiness (#4348), shared by the Instances peer card and the System Health capacity panel so the two cannot disagree. `resolvePeerMediaReadiness(peer, { now })` maps the stored probe onto a display state/label/tone plus its remedy text, queue snapshot, capabilities, and per-kind allowlists; it re-derives `stale` when the snapshot’s `freshUntil` has passed, so a snapshot probed as `ready` cannot keep reading `ready` after the server would refuse to submit against it. Also exports `FEDERATED_MEDIA_KINDS` (audio/image/video + their config fields and icons), the state/help tables, and `peerMediaProviderConfig`. `summarizePeerMediaQueue(queue)` renders the peer’s queue block into finished display segments — slot occupancy, how many jobs drain in parallel, and which kinds are busy — so every surface shows the same words, dropping any segment an older provider did not send rather than showing it as a zero. `federatedMediaModelsForPeer(peer, kind)` intersects the local per-kind allowlist with the peer’s advertised capabilities and returns the capability entries (with their `ready` / `unavailableReason`), so no surface can offer a model the server would refuse or hide why a listed one cannot run. `federatedMediaSupports(status, feature, capability)` mirrors the server helper of the same name and is the ONE place the "absent reads as false" rule lives on this side (#4826): it answers whether the peer’s BUILD speaks a wire feature (`lyrics`, `inputAssets`) from the status-root `features` list, retaining only the legacy `inputAssets` capability block for peers on the previous build because that signal is genuinely per-model, and `peerMediaProviderSnapshot(peer)` returns the status payload to ask it against. `peerModelAcceptsInput(capability, role, status)` / `peerModelRequiresInput(capability)` read the capability’s `inputAssets` block — which conditioning slots (`initImage` / `referenceImages` / `sourceImage` / `lastImage`) a peer model takes, and whether it can render without one — gated first on the build speaking conditioning at all, with **absent reading as NO**, so a provider predating that field is never offered a render it would reject. Display only — the server re-probes and fail-closes before any job leaves. | | `fableLoomReadiness.js` | Ordered FableLoom readiness plus the twelve-stage producer workflow spanning foundation, challenges, outlines, editorial, continuity, media, and final hosting. | | `glbFailure.js` | Turns a 3D asset failure into an actionable sentence. `glbFailureHint(error)` matches the message against a shared table (an HTML body reaching the glTF parser, a WebGL context failure, a 404) and returns `null` when nothing recognizes it — so a caller can tell "we know the cause" from "we don't" and fall back to the raw text. `glbErrorText(error)` reads a message off whatever shape was thrown. Shared because the CoS avatars load remote GLBs too: every caught error there used to be reported as "this display has no WebGL", sending the user to change an unrelated setting (#4688). Consumed by `media/GlbViewer.jsx` and `cos/CoSCanvasGuard.jsx`. | -| `grokVideoClip.js` | Mirror of `server/lib/grokVideoClip.js` — `GROK_VIDEO_DURATIONS` (`[6, 10]`) + `GROK_VIDEO_DEFAULT_DURATION`, the clip lengths grok's `image_to_video` actually delivers. | -| `reactorVideoClip.js` | Mirror of `server/lib/reactorVideoClip.js` — `REACTOR_MAX_PROMPT_LENGTH` (800, counted in the VideoGen prompt field before submit), `REACTOR_MIN_CLIP_SECONDS` / `REACTOR_MAX_CLIP_SECONDS`, the picker list `REACTOR_CLIP_LENGTHS` + `REACTOR_DEFAULT_CLIP_LENGTH` + `reactorClipLengthLabel()`, and the canvas contract `REACTOR_CANVASES` / `REACTOR_ASPECTS` / `REACTOR_DEFAULT_ASPECT` / `reactorCanvas()` / `nearestReactorAspect()` the aspect picker builds from. | +| `grokVideoClip.js` | Re-export of `server/lib/grokVideoClip.js` — `GROK_VIDEO_DURATIONS` (`[6, 10]`) + `GROK_VIDEO_DEFAULT_DURATION`, the clip lengths grok's `image_to_video` actually delivers. | +| `reactorVideoClip.js` | Re-export of `server/lib/reactorVideoClip.js` — `REACTOR_MAX_PROMPT_LENGTH` (800, counted in the VideoGen prompt field before submit), `REACTOR_MIN_CLIP_SECONDS` / `REACTOR_MAX_CLIP_SECONDS`, the picker list `REACTOR_CLIP_LENGTHS` + `REACTOR_DEFAULT_CLIP_LENGTH` + `reactorClipLengthLabel()`, and the canvas contract `REACTOR_CANVASES` / `REACTOR_ASPECTS` / `REACTOR_DEFAULT_ASPECT` / `reactorCanvas()` / `nearestReactorAspect()`. | | `imageCleaners.js` | Mirror of `resolveCleanersFromConfig` from `server/lib/imageClean.js`. Reads `{cleanC2PA, denoise}` off a per-mode settings record. `cleanC2PA` defaults are mode-aware (on for `codex` + `external` — the backends that emit C2PA chunks today — off otherwise, as an allow-list rather than a deny-list); `denoise` defaults off everywhere (lossy, opt-in only). | | `reviewerModels.js` | `reviewerModelsFromDefaults` / `reviewerModelsToDefaults` / `reviewerEffortsFromDefaults` / `reviewerEffortsToDefaults` — adapt the Code Review Defaults' per-reviewer SCALARS (`codexModel`, `ollamaModel`, `claudeEffort`, …) to and from the token-keyed maps `ReviewerPicker`'s Model and Effort columns take. Mirrors `reviewerModelsFromDefaults` / `reviewerEffortsFromDefaults` in `server/lib/reviewerConfig.js`. | | `reviewerPins.js` | Client mirror of the whole reviewer vocabulary — the roster (`REVIEWER_VALUES`), `REVIEWER_ALIASES`, `DEFAULT_REVIEWER(S)`, `normalizeReviewers`, the review-username pattern/cap (`MAX_REVIEW_USERNAMES` / `cleanReviewUsername` / `normalizeReviewUsernames`), `MAX_REVIEWER_MAX_ROUNDS`, `REVIEW_STOP_MODES` / `DEFAULT_REVIEW_STOP_MODE`, plus the per-reviewer pin vocabularies (`MODEL_SELECTABLE_REVIEWERS` / `EFFORT_SELECTABLE_REVIEWERS` / `REVIEWER_EFFORT_LEVELS` / `reviewerEffortLevels` / `normalizeReviewerSlug` / `sanitizeReviewerModelInput`) and their bounds. Dependency-free on purpose: the server suite imports it to pin the mirror against `server/lib/reviewerConfig.js`, so it must not reach for a client-only package. Re-exported by `components/cos/constants.js`. | -| `loraEffect.js` | Mirror of the LoRA adapter-effect report vocabulary in `server/lib/loraEffect.js` (#4872): `LORA_EFFECT_STATUSES` (`ok`/`zero`/`nonfinite`/`unreadable`/`unmeasurable`), `LORA_EFFECT_BADGES` + `loraEffectBadge(status)` (badge label and Tailwind tone — only `zero` is styled as an error, because only `zero` refuses a render server-side), and `formatLoraEffect(report)`, the same one-line summary the server logs, returning `null` when the badge already says everything. Pinned by `loraEffect.parity.test.js`. | -| `loraTriggers.js` | Mirror of the trigger-word predicates in `server/lib/loraTriggers.js` (#4665): `firstTriggerWord` (the canonical activation token of a Civitai `trainedWords` list) and `promptHasTriggerWord` (whole-token, case-insensitive presence — `aria_tok` does not match inside `aria_token`). Plus `appendTriggerWords(prompt, words, effectivePrompt?)` — the "+ trigger" button's append, which adds ALL of a LoRA's words (the server only ever weaves the first) and judges presence against the composed prompt the page will actually submit. Lets the LoRA picker warn that the server is about to append a token, with the button and the hint sharing one presence rule. | +| `loraEffect.js` | How the LoRA manager card renders an adapter-effect measurement (#4872). `LORA_EFFECT_STATUSES` is re-exported from `server/lib/loraEffect.js`; `LORA_EFFECT_BADGES` + `loraEffectBadge(status)` (only `zero` is styled as an error, because only `zero` refuses a render server-side) and `loraEffectDetail(report)` are client-only — the detail line returns `null` when the badge already says everything, where the server's `formatLoraEffect` always returns a sentence for its log. | +| `loraTriggers.js` | The picker's `+ trigger` append (#4665). `firstTriggerWord` / `promptHasTriggerWord` / `separatorFor` are re-exported from `server/lib/loraTriggers.js`, so the hint reads the rule the render enforces. `appendTriggerWords(prompt, words, effectivePrompt?)` is client-only: it adds ALL of a LoRA's words (the server only ever weaves the first) and judges presence against the composed prompt the page will actually submit. | | `runnerFamilies.js` | Mirror of `server/lib/runners.js`, including the shared LTX + MiniMax H3 Ref2VA audio-to-video runtime predicate. | -| `slashdoCatalog.js` | Mirror of `server/lib/slashdoCatalog.js` — the launchable slashdo workflows (`SLASHDO_WORKFLOWS`, `slashdoWorkflowsForApp(isSwiftApp)`, `SLASHDO_APP_TYPES`) driving the app-overview Agent Operations buttons. Adds only the per-button Tailwind classes; pinned by `server/lib/slashdoCatalog.test.js`. | -| `isSafeHref.js` | Pure http(s)-only scheme check (`isSafeHref`) for user-supplied URL fields rendered as a clickable `` — rejects `javascript:`/`data:`/etc. stored-XSS payloads. Mirror of `server/lib/isSafeHref.js`. Re-exported as `isHttpUrl` from `client/src/utils/urlNormalize.js` for existing importers. | -| `issueLength.js` | Mirror of `server/lib/issueLength.js`. | +| `slashdoCatalog.js` | The Agent Operations buttons. `SLASHDO_WORKFLOWS` is `server/lib/slashdoCatalog.js`'s list decorated with a per-command Tailwind tone, and `slashdoWorkflowsForApp(isSwiftApp)` filters it through the server's own `slashdoWorkflowAppliesTo` — so the panel cannot be a workflow short. `SLASHDO_NAMESPACE` + `slashdoLabel(command)` (the `/do:x` UI spelling) stay local: the server module that owns the namespace reaches for the provider registry. | +| `isSafeHref.js` | Pure http(s)-only scheme check (`isSafeHref`) for user-supplied URL fields rendered as a clickable `` — rejects `javascript:`/`data:`/etc. stored-XSS payloads. Re-exported from `server/lib/isSafeHref.js`; `utils/urlNormalize.js` re-exports it again as `isHttpUrl`. | +| `issueLength.js` | The Length Profile picker's helpers. `LENGTH_PROFILES`, `DEFAULT_LENGTH_PROFILE` and the `CUSTOM_*` bounds are re-exported from `server/lib/issueLength.js` (the same profiles the server computes targets from); `clampInt` and `summarizeLengthProfile` are client-only form/display helpers. | | `musicDuration.js` | Lyric-aware MiniMax Music 3 duration analysis: section/word detection, ending-cushioned auto-duration recommendations, and cap warnings. Mirrors `server/lib/musicDuration.js`. | ## Pipeline / image-gen defaults @@ -84,7 +90,7 @@ grep -i "what you want to do" client/src/lib/README.md | `videoGenParams.js` | Pure VideoGen param helpers: `FRAME_OPTIONS`/`FPS_OPTIONS`/`VIDEO_EDGE_BOUNDS`/`MAX_CHUNKS`/`CHUNK_OPTIONS`/`DEFAULT_CONTEXT_FRAMES`/`CONTEXT_FRAME_OPTIONS` constants, model-aware frame/fps/resolution-grid normalization, separate mute vs prompt-audio capability checks, `supportsContextWindow(model)` (does this runtime have an extend pipeline to feed a continuation window to — display mirror of `server/lib/videoContinuity.js`, pinned by `server/lib/videoContinuity.parity.test.js`), `videoModelMemoryGb()` (model memory footprint), `selectVideoMemoryProfile(model, systemMemoryGb)` + `VIDEO_MEMORY_RESERVE_GB` (#5420 — which declared weight-placement profile this machine can actually hold, out of the `memoryProfiles` the server decorates onto the entry; the floors ride on the model so only the reserve is mirrored, and an unmeasured `systemMemoryGb` returns a `null` usable figure rather than reading as a box that is too small), `computeFflfSafeFrames()` (FFLF/ltx2 pixel-budget back-solve, mirrors `server/services/videoGen/local.js`), and `isModelAllowedForMode()` (a2v uses the shared audio-runtime capability; IC remix remains LTX-only). Speed profiles (#4875) mirror the conditioner shape: `DEFAULT_SPEED_PROFILE_ID` (must equal `SPEED_PROFILE_DEFAULT_ID` in `server/lib/videoSpeedProfiles.js` — absence and `'quality'` are the same request), `isDefaultSpeedProfileId()` mirrors the server's absence-is-the-default rule, `speedProfilesForModel()` reads the server-decorated `speedProfiles` off the entry, `speedProfilesForMode(model, mode)` applies the SAME mode gate the server's `speedProfileDeclineReason` does (so a profile the server would decline is never offered, nor allowed to lock the dials), `normalizeSpeedProfileForModel()` snaps a selection onto what a just-switched model declares (mode-independent, so switching to fflf hides the picker without rewriting the choice), `speedProfileIdFromRecord()` reads one back out of a history entry / resumed job, `selectedSpeedProfile(id, model, mode)` resolves the profile actually driving the render — what the picker shows and what disables Steps+CFG — and `videoChainChunkModes({ model, mode, chaining, contextFrames, hasSourceImage })` derives the modes a CHAINED request’s chunks will run in (chunk 0 the request’s, chunks 1+ `extend` on a window-continuity chain or `image` on a frame hop), mirroring `generateChainedVideo`’s dispatch and `resolveContinuityStrategy`; `resolveContextFramesForDisplay()` is the absent/invalid→`DEFAULT_CONTEXT_FRAMES` half of `resolveContextFrames` that gate depends on. All pinned by `server/lib/videoSpeedProfiles.parity.test.js`. Also mirrors the IC-LoRA remix registry (`IC_LORA_MODES`/`IC_LORA_MODE_VALUES`/`isIcLoraMode()`/`icLoraSpecForMode()`/`icResolutionIssue()`) from `server/lib/icLoraWeights.js` so the form validates a reference render pre-submit — pinned by `server/lib/icLoraWeights.parity.test.js`. | | `videoGenResolutions.js` | Shared resolution presets/default for video generation, model-specific preset/default resolvers (for native canvases such as MiniMax H3), and `snapAspectToImage()` to pick the closest-aspect preset for an I2V source. | | `videoGenSubmission.js` | Builds the local, Grok, and federated video-generation request bodies from validated form state, including prompt envelopes and empty-value wire sentinels. | -| `videoReferenceModes.js` | Mirror of `server/lib/videoReferenceModes.js` (parity enforced by `server/lib/videoReferenceModes.mirror.test.js`) — the i2v reference-mode contract: `I2V_REFERENCE_MODES`, `I2V_REFERENCE_MODE_OPTIONS` (label + the promise sentence `AdvancedParamsPanel` and the source-frame note print), `runtimeSupportsI2vReferenceMode` (gates which options the picker offers), `resolveI2vReferenceStrength` (the effective strength the panel displays), and `i2vReferenceModeViolation` for pre-submit feedback. | +| `videoReferenceModes.js` | Re-export of `server/lib/videoReferenceModes.js` — the i2v reference-mode vocabulary and its per-mode rules. | | `videoRenderPhase.js` | Video render phase → named progress step (#5872). `resolveVideoRenderSteps({ generating, phase, progressPct })` → `{ activeId, steps: [{ id, label, state: 'done'|'active'|'pending' }] }`, collapsing the runners' fine-grained `STAGE:` vocabulary (`load-transformer`, `encode-prompt`, `sampling`, `mux`, …) onto six steps a person can read — the queue's own `queued` is one of them, so a caller needs no separate flag. Family prefixes (`download-*`, `load-*`, `wan-*`, …) absorb markers a future runner adds; `videoRenderStepFor(phase)` returns `null` for a genuinely unknown one, never step 0. Consumed by `components/videoGen/RenderStatusCard.jsx`. | | `videoTilingOptions.js` | `VIDEO_TILING_OPTIONS` (the `` never lies) — feeding the shared `CycleTarget` control used by both the walk workflow and the trimmer's re-derive panel (#2980). Pure — no React, no canvas, no I/O. | | `spriteWalkUnlock.js` | The user-facing copy for a BLOCKED walk re-open (#3043/#3044), shared by the Walk Cycles header, per-direction cards, and Loop Trimmer so one server-stamped block can never be described two different ways. `walkUnlockCopy(unlock, { mode?, direction? })` → `{ text, action, prompt, acknowledgeNoClips, toast }` for a blocked scope (null when it is not blocked): a blocked-but-`acknowledgeable` scope gets the regeneration offer plus the `acknowledgeNoClips` consent flag its POST carries, a non-acknowledgeable one gets the dead-end explanation and no action. `WALK_UNLOCK_TOAST` is the ordinary unlock success message. Pure — no React, no I/O. | | `tribe.js` | Pure Tribe domain helpers shared by the Tribe page and its `TribeCircleMap` visualization: `RINGS` (support→core→tribe→village Dunbar rings + the uncapped `external` "outside the tribe" classification — caps, cadence defaults, Tailwind tones + `hex`), `TRIBE_RINGS` (the four inner rings the care queue/capacity/overdue counts operate over, excluding `external`), `ENERGY` (energy tiers + `hex`), `STATUS_HEX` (cadence-status → SVG stroke color), `contactStatus(contact)` (presentation wrapper over the shared `cadenceStatus` — attaches label/tone to the missing/overdue/soon/steady/`external` state) / `daysBetween(date)` (alias of the shared `daysSinceDate`), `ringFor` / `energyFor` lookups, `tagsToArray` / `tagsToInput`, `initialsFor(name)` (node glyph), and the care-state filters shared by the summary tiles / roster / care queue (`STATUS_FILTERS`, `STATUS_FILTER_IDS`, `STATUS_FILTER_LABELS`, `matchesStatusFilter(contact, filter)` — unknown ids degrade to `all`). Ring `cadenceDays` mirror `DEFAULT_RING_CADENCE` in `server/services/tribe.js`; the cadence STATE MACHINE lives in `tribeCadence.js`. | -| `tribeCadence.js` | Mirror of `server/lib/tribeCadence.js` — the single authoritative Tribe care-cadence rules shared by the page, the Care dashboard widget, and the proactive alert: `cadenceStatus(entity)` → `{ state, daysRemaining, daysOverdue }`, `daysSinceDate(dateStr)`, `DEFAULT_CADENCE_DAYS`, `SOON_WINDOW_DAYS`. A cross-boundary contract test asserts it never drifts from the server copy. | +| `tribeCadence.js` | Re-export of `server/lib/tribeCadence.js` — the single authoritative Tribe care-cadence rules shared by the page, the Care dashboard widget, and the server. | | `streakGlyph.js` | `streakGlyph(streak)` — the single POST streak emoji (✨ / ⚡ / 🔥 by day count) shared by the POST launcher, Morse trainer, daily widget, and dashboard streak widget. | | `syncCounts.js` | Directional pending-sync counts for the Instances federation cards. `directionalCounts({ localMax, peerMax, ourCursor, peerCursorForUs })` → `{ toPull, toPush }` (each a non-negative Number, or `null` when unknown) from monotonic change-log seqs; `diffSeq(ahead, behind)` is the BigInt-safe clamp-at-zero subtraction (handles numeric strings past `MAX_SAFE_INTEGER`); `describeDirectional({ toPull, toPush })` renders the plain-language `{ state, text }` ("in sync" / "N to pull · M to push" / "checking…"). No React — `SyncStatusBadge` drives it. | | `tabNotation.js` | Guitar-tab / chord-sheet / ChordPro text parser for SongBook (`/songbook`). `parseTabSheet(text)` → `{ lines, meta, errors }` classifying each line (`section`/`chords`/`lyric`/`tabstaff`/`chordlyric`/`blank`/`text`) with chord names + column offsets, plus ChordPro meta (`{title:}`/`{artist:}`/`{key:}`/`{capo:}`); `detectFormat(text)` → `'chordpro' \| 'tab' \| 'plain'`; `normalizePastedTab(text)` cleans HTML remnants/entities, CRLF, tabs→spaces, blank-line runs; `transposeChordName(name, n)` / `transposeText(text, n)` transpose chord symbols (slash chords, sensible sharp/flat spelling, column-preserving on chord lines); `TAB_ARTICULATIONS` documents tab-staff technique characters for the viewer legend; `CHORD_TOKEN_RE` exported for reuse, plus `NOTE_TO_PC` / `spellPitchClass` exported minimally so `chordShapes.js` derives voicings from the same pitch-class tables. Forgiving — never throws, unknown lines classify as `text`. NOT the lead-sheet parser (`scoreNotation.js`). | @@ -214,7 +220,7 @@ grep -i "what you want to do" client/src/lib/README.md | `terminalDictation.js` | Voice-dictation/IME bridge for the Shell's xterm.js terminal. Apple dictation streams progressively refined guesses and *replaces* what it already typed; xterm forwards each insertion and drops the matching deletions, so the PTY accumulates a garble (`determin` + `determine` + `determines`…). `attachDictationBridge(terminal, sendData)` binds capture-phase listeners on `terminal.element` — ahead of xterm's own textarea listeners, which it stops — and forwards a diff instead: `TERMINAL_DEL` (`0x7f`) per dropped character, then the added text. `sendData` returns `false` to report a dropped send, which leaves the mirror where the PTY actually is. Paths that reach the PTY some other way (a keystroke xterm handles itself, paste, blur, composition end, screen-reader mode) resync the mirror without emitting. `planFieldEdit(mirror, next, floor)` → `{ data, committed }` is the pure core: `floor` is the prefix we did not write and must never rewind through, and `committed` (what the PTY holds afterward, which is *not* the field's value when the floor blocked a rewind) is what the caller must track. The prefix scan never splits a surrogate pair — cutting between the halves of an astral character would send a lone surrogate that serializes to `U+FFFD`. Wired in `useShellSession`; the test suite pins the seam against a real `Terminal`. | | `terminalScroll.js` | Scrolling the Shell terminal by touch, wheel, and page controls. xterm 6 binds **no touch handlers at all**, and a TUI’s ALTERNATE screen buffer has no terminal scrollback, so `scrollLines()` clamps to a no-op there. Normal shell scrollback uses `scrollLines()`; alternate-screen wheel gestures are captured before xterm’s mouse listener and translated to standard PageUp/PageDown input, which OpenCode supports for its message viewport, while apps that explicitly enable terminal mouse tracking keep xterm’s native wheel path. `attachTerminalTouchScroll(terminal)` (→ detach fn) makes a one-finger drag scroll in either buffer, `attachTerminalWheelScroll(terminal)` handles native wheel input, and `scrollTerminalPage(terminal, direction)` powers the `SCROLL_KEYS` buttons. `measureTerminalGeometry(terminal)` takes one layout read per gesture; `planTouchScrollSteps(accumPx, rowHeightPx)` is the pure sub-row-remainder core. Wired in `useShellSession`. | | `terminalTheme.js` | Pure xterm.js palette builder for the Shell terminal. `buildTerminalTheme({ bg, fg, accent, card, error, success, warning }, mode)` assembles the xterm `theme` object — base ANSI colors from the active theme's CSS vars, the rest from mode-tuned literals (`ANSI_NIGHT` bright/pastel for dark backgrounds, `ANSI_DAY` darkened/saturated so colored CLI output stays legible on daytime themes). `parseCssColorToHex(raw, fallback)` normalizes both the `15 15 15` triple form and the `rgb(7 7 7 / 0.86)` function form (used by `--port-terminal-*` tokens) to `#rrggbb`, dropping alpha. No DOM reads — `Shell.jsx` resolves CSS vars and re-applies on theme switch. | -| `textUtils.js` | Mirror of `server/lib/textUtils.js` (the `escapeRegExp` half — the only member the bundle has a caller for). `escapeRegExp(value)` is the one client-side RegExp escape: import it instead of re-inlining the character class, which a guard in `server/lib/textUtils.test.js` now fails the suite over on either side of the mirror. Coerces non-strings rather than throwing, matching the server. | +| `textUtils.js` | `escapeRegExp(value)` re-exported from `server/lib/textUtils.js` — the one member the bundle has a caller for. Named export only, so the server's `countWords` does not collide with the one in `utils/formatters.js`. | | `threejsAnimation.js` | Pure pose evaluator for the declarative clip contract in `server/lib/threejsModel.js`, used by the Three.js Models preview transport. `evaluateThreejsClipPose(clip, timeSeconds)` → `{ timeSeconds, pose, activeSequenceIds, activePartIds }` where `pose` is a null-prototype partId → `{ position?, rotationDegrees?, scale?, opacity?, visible? }` map: each part+channel resolves from the one sequence that owns the instant (the window containing it, else the most recent behind it, else the next ahead), `visible` steps at the window's end rather than interpolating, and a part no sequence drives is absent so the spec renders as authored. `collectThreejsCues(clip, from, to)` returns the data-only sound cues a play loop crossed in the half-open `[from, to)` interval — a scrub calls it never, which is what makes scrubbing silent. `listThreejsClips` / `listThreejsCues` / `resolveThreejsClip` / `getThreejsClipDuration` read a spec that may have no `animation` key at all. | | `threejsEnvironment.js` | Image-based lighting for the procedural sculpt spec, used by the Three.js Models preview. `spec.lights` are punctual — they light a surface but give it nothing to REFLECT, so `metalness`, `transmission`, `clearcoat` and `iridescence` read off an environment or read off nothing, and a plausible conductor renders near-black without one. `createSculptEnvironmentTarget(renderer, preset)` builds a preset locally from three's own primitives and prefilters it through `PMREMGenerator` — `neutral` is three's bundled `RoomEnvironment`, `studio` a dark shell with three emissive softbox panels plus a floor bounce — returning null for `none`; the caller owns the render TARGET and disposes it (disposing only `.texture` leaks the framebuffer behind it). `THREEJS_RENDER_PROFILE` mirrors the server's colour-space/tone-map/exposure contract so the preview renders at what the export stamps on the model. Deliberately NOT drei's ``, which fetches an HDR from a CDN: rendering a local model makes no outbound request. `resolveSculptEnvironment(spec)` is the client mirror of `resolveThreejsEnvironment` in `server/lib/threejsModel.js`, reading a spec with no `environment` key as the `none` it was actually authored against. | | `threejsExplode.js` | Disassembly + part-picking maths for the procedural sculpt spec, shared by the Three.js Models preview so explode and the picker agree on what "a part" is. `computeExplodeLayout(parts, amount)` → `{ offsets, meshOffsets, unitIds, growth }`: separation is a layout **scale about the model centre** (≈2× at full explode) plus a base clearance for parts sitting at the centre — never a uniform outward push, which slides the arrangement without opening gaps — where `offsets` add to a unit's own `position` and `meshOffsets` apply to a group around a container's own geometry (moving its group instead would drag its child units along), and `growth` measures how much the layout actually grew so the camera re-fits on real change. `buildPartSelectionIndex(parts)` → `{ owners, ancestry, names }` for click resolution, subtree highlight, and the selection label. All maps are null-prototype — part ids are provider-authored and `idSchema` accepts `toString`. `isReliefPart` / `isContainerPart` expose the shared part definition: a part flagged `explodeWithParent` is surface relief that rides its parent and resolves selection up to it (unless it has no parent to ride), every other geometry-bearing part is both a movable unit and a selectable component, a part whose descendants carry geometry is additionally descended through, and a part with no geometry in its subtree moves nothing. | diff --git a/client/src/lib/appIdentity.js b/client/src/lib/appIdentity.js index 4f1e977a4a..8fb647f755 100644 --- a/client/src/lib/appIdentity.js +++ b/client/src/lib/appIdentity.js @@ -1,18 +1,8 @@ /** - * The managed-apps registry's baseline identity — PortOS itself. Mirrors - * `server/lib/appIdentity.js`. + * The product name/tagline every surface prints. * - * Split out of `services/apiCore.js` for the same reason the server split it out - * of `services/apps.js`: a module that only needs to SAY "this record is PortOS" - * shouldn't have to import the API client — which pulls in `ui/Toast` and - * therefore React. `client/src/components/apps/constants.js` is imported by a - * node-env SERVER test (`server/services/streamingDetect.test.js`, the - * DESKTOP_TYPES parity check), where that React import fails to resolve. - * `apiCore.js` re-exports the constant, so every existing - * `import { PORTOS_APP_ID } from '../services/api'` is unchanged. - * - * Data only, no dependencies — keep it that way. + * Re-export of `server/lib/appIdentity.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/appIdentity` import path in the client is unchanged. */ - -/** Stable id of the baseline PortOS app — always present, never deletable. */ -export const PORTOS_APP_ID = 'portos-default'; +export { PORTOS_APP_ID } from '../../../server/lib/appIdentity.js'; diff --git a/client/src/lib/assetProvenance.js b/client/src/lib/assetProvenance.js index 98c7fa3cba..f412e67e4d 100644 --- a/client/src/lib/assetProvenance.js +++ b/client/src/lib/assetProvenance.js @@ -1,198 +1,25 @@ /** - * Asset license provenance — stamp at finalize time, never re-read later. + * Provenance vocabulary + readers for a generated asset. * - * PortOS already resolves a model's license when it downloads one - * (`licenseOf` in huggingFaceCatalog, Civitai/HF LoRA cards) and then drops - * it on the floor. Create-suite outputs leave the machine (collections, - * pipeline export, albums), so the terms that applied WHEN THE PIXELS WERE - * MADE have to travel with the asset. A license re-read months later can - * differ from the one in force at render; unknown stays unknown (`null`), - * displayed as "unknown" — never a permissive default. - * - * Shape (schemaVersion 1): - * { - * schemaVersion: 1, - * capturedAt: ISO-8601 | null, - * sources: [{ kind: 'model'|'lora', id, name, license, sourceUrl }] - * } - * - * Pure — no I/O. Server and client share this module byte-for-byte. + * Re-export of `server/lib/assetProvenance.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/assetProvenance` import path in the client is unchanged. */ - -export const PROVENANCE_SCHEMA_VERSION = 1; -export const PROVENANCE_SOURCE_KINDS = Object.freeze(['model', 'lora']); -export const UNKNOWN_LICENSE_LABEL = 'unknown'; - -export function normalizeLicense(value) { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed || null; -} - -export function licenseLabel(license) { - const normalized = normalizeLicense(license); - return normalized || UNKNOWN_LICENSE_LABEL; -} - -export function huggingfaceUrl(repo) { - if (typeof repo !== 'string') return null; - const id = repo.trim(); - return id ? `https://huggingface.co/${id}` : null; -} - -export function licenseFromHuggingFaceModel(model) { - const card = normalizeLicense(model?.cardData?.license || model?.license); - if (card) return card; - const tags = Array.isArray(model?.tags) ? model.tags : []; - const tag = tags.find((t) => typeof t === 'string' && /^license:/i.test(t)); - return tag ? normalizeLicense(tag.slice(tag.indexOf(':') + 1)) : null; -} - -export function licenseFromCivitaiModel(model) { - // Civitai's `allowCommercialUse` is a policy flag, not a license string — - // never promote it into one. Only a real `license` field counts. - return normalizeLicense(model?.license); -} - -export function buildProvenanceSource({ kind, id, name = null, license = null, sourceUrl = null } = {}) { - if (!PROVENANCE_SOURCE_KINDS.includes(kind)) return null; - if (typeof id !== 'string' || !id.trim()) return null; - const url = typeof sourceUrl === 'string' && sourceUrl.trim() ? sourceUrl.trim() : null; - const display = typeof name === 'string' && name.trim() ? name.trim() : null; - return { - kind, - id: id.trim(), - name: display, - license: normalizeLicense(license), - sourceUrl: url, - }; -} - -const sourceKey = (src) => `${src.kind}:${src.id}`; - -export function buildProvenance({ sources = [], capturedAt = null } = {}) { - const captured = typeof capturedAt === 'string' && capturedAt.trim() ? capturedAt.trim() : null; - const byKey = new Map(); - for (const raw of Array.isArray(sources) ? sources : []) { - const src = buildProvenanceSource(raw); - if (!src) continue; - const key = sourceKey(src); - const existing = byKey.get(key); - if (!existing) { - byKey.set(key, src); - continue; - } - // Prefer a known license over unknown when the same source appears twice - // in one stamp (model + LoRA list shouldn't collide, but a rollup can). - if (existing.license == null && src.license != null) { - byKey.set(key, { - ...existing, - license: src.license, - name: existing.name || src.name, - sourceUrl: existing.sourceUrl || src.sourceUrl, - }); - continue; - } - byKey.set(key, { - ...existing, - name: existing.name || src.name, - sourceUrl: existing.sourceUrl || src.sourceUrl, - }); - } - return { - schemaVersion: PROVENANCE_SCHEMA_VERSION, - capturedAt: captured, - sources: [...byKey.values()], - }; -} - -export function readProvenance(record) { - const raw = record?.provenance; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; - const built = buildProvenance({ - sources: Array.isArray(raw.sources) ? raw.sources : [], - capturedAt: raw.capturedAt, - }); - return built.sources.length ? built : null; -} - -function pickLoraFilenames(record) { - if (Array.isArray(record?.loraFilenames)) return record.loraFilenames; - if (Array.isArray(record?.lora_filenames)) return record.lora_filenames; - return []; -} - -export function resolveAssetProvenance(record) { - const stamped = readProvenance(record); - if (stamped) return { ...stamped, reconstructed: false }; - if (!record || typeof record !== 'object') return null; - const modelId = record.modelId || record.model; - const loras = pickLoraFilenames(record).filter((f) => typeof f === 'string' && f); - if (!modelId && !loras.length) return null; - const capturedAt = typeof record.createdAt === 'string' ? record.createdAt : null; - return { - ...buildProvenance({ - sources: [ - ...(modelId ? [{ kind: 'model', id: String(modelId), license: null }] : []), - ...loras.map((id) => ({ kind: 'lora', id, license: null })), - ], - capturedAt, - }), - reconstructed: true, - }; -} - -export function licenseFromRegistryModel(model) { - // Weights terms only. `disclosure.runtimeLicense` is the inference stack - // (often MIT) and must never be promoted into the asset's model license. - return normalizeLicense(model?.license) - || normalizeLicense(model?.disclosure?.weightsLicense?.name); -} - -export function provenanceForRender({ model = null, loras = [], capturedAt = null } = {}) { - const sources = []; - if (model && (model.id || model.name)) { - const id = String(model.id || model.name); - const disclosureUrl = typeof model.disclosure?.modelCardUrl === 'string' - ? model.disclosure.modelCardUrl - : null; - const weightsUrl = typeof model.disclosure?.weightsLicense?.url === 'string' - ? model.disclosure.weightsLicense.url - : null; - sources.push({ - kind: 'model', - id, - name: model.name || null, - license: licenseFromRegistryModel(model), - sourceUrl: model.sourceUrl || huggingfaceUrl(model.repo) || disclosureUrl || weightsUrl, - }); - } - for (const lora of Array.isArray(loras) ? loras : []) { - const filename = typeof lora === 'string' ? lora : lora?.filename; - if (typeof filename !== 'string' || !filename) continue; - sources.push({ - kind: 'lora', - id: filename, - name: typeof lora === 'object' ? (lora.name || null) : null, - license: typeof lora === 'object' ? lora.license : null, - sourceUrl: typeof lora === 'object' ? lora.sourceUrl : null, - }); - } - return buildProvenance({ sources, capturedAt }); -} - -export function rollupProvenance(records) { - const sources = []; - for (const record of Array.isArray(records) ? records : []) { - const resolved = resolveAssetProvenance(record); - if (!resolved) continue; - sources.push(...resolved.sources); - } - return buildProvenance({ sources, capturedAt: null }); -} - -export function formatProvenanceSource(src) { - const built = buildProvenanceSource(src); - if (!built) return null; - return { ...built, licenseLabel: licenseLabel(built.license) }; -} +export { + PROVENANCE_SCHEMA_VERSION, + PROVENANCE_SOURCE_KINDS, + UNKNOWN_LICENSE_LABEL, + buildProvenance, + buildProvenanceSource, + formatProvenanceSource, + huggingfaceUrl, + licenseFromCivitaiModel, + licenseFromHuggingFaceModel, + licenseFromRegistryModel, + licenseLabel, + normalizeLicense, + provenanceForRender, + readProvenance, + resolveAssetProvenance, + rollupProvenance, +} from '../../../server/lib/assetProvenance.js'; diff --git a/client/src/lib/avatarStyles.js b/client/src/lib/avatarStyles.js index 0dc806634a..95c4bf325f 100644 --- a/client/src/lib/avatarStyles.js +++ b/client/src/lib/avatarStyles.js @@ -1,38 +1,16 @@ /** - * Single source of truth for the CoS avatar-style vocabulary. Every consumer - * that used to hand-maintain its own list derives from `AVATAR_STYLES` - * instead (#6253): the picker labels (`components/cos/constants.js`), the - * lazy-load map and WebGL-stage set (`pages/ChiefOfStaff.jsx`), and the - * server's `avatarStyle` zod enum (`server/routes/cosStatusRoutes.js`, - * imported directly — this leaf has no transitive deps, so it's safe from - * the server workspace the way `personaTraitBlend.js`'s `clamp` import is). + * Re-export of the authoritative CoS avatar-style vocabulary in + * `server/lib/avatarStyles.js`. * - * `webgl: true` marks a style that needs the three.js canvas stage — - * `CANVAS_AVATAR_STYLES` derives from this flag. The 2D `core` canvas style - * and the inline `svg`/`ascii` styles are deliberately `webgl: false`. + * The registry lives server-side because the server's `avatarStyle` zod enum + * needs it too, and the dependency direction is one-way: the client imports + * pure `server/lib` leaves, never the reverse (a client-only dependency added + * to a file the server imports breaks the server CI job). This shim keeps the + * `lib/avatarStyles` import path every UI consumer already uses. */ - -export const AVATAR_STYLES = [ - { id: 'svg', label: 'Digital (SVG)', webgl: false }, - { id: 'cyber', label: 'Cyberpunk (3D)', webgl: true }, - { id: 'sigil', label: 'Arcane Sigil (3D)', webgl: true }, - { id: 'esoteric', label: 'Esoteric (3D)', webgl: true }, - { id: 'nexus', label: 'Neural Nexus (3D)', webgl: true }, - { id: 'muse', label: 'Cyber Muse (3D)', webgl: true }, - // Kestrel Neon's rotating wireframe icosahedron — 2D canvas, no WebGL needed. - { id: 'core', label: 'Core Assembly (Canvas)', webgl: false }, - // Bundled CC0 Kenney Mini Characters — animated rigged GLB avatars. - { id: 'miniMaleC', label: 'Mini Character — Male (3D)', webgl: true }, - { id: 'miniFemaleD', label: 'Mini Character — Female (3D)', webgl: true }, - { id: 'ascii', label: 'Minimalist (ASCII)', webgl: false }, -]; - -export const AVATAR_STYLE_IDS = AVATAR_STYLES.map((style) => style.id); - -export const AVATAR_STYLE_LABELS = Object.fromEntries( - AVATAR_STYLES.map((style) => [style.id, style.label]) -); - -export const WEBGL_AVATAR_STYLE_IDS = new Set( - AVATAR_STYLES.filter((style) => style.webgl).map((style) => style.id) -); +export { + AVATAR_STYLES, + AVATAR_STYLE_IDS, + AVATAR_STYLE_LABELS, + WEBGL_AVATAR_STYLE_IDS, +} from '../../../server/lib/avatarStyles.js'; diff --git a/client/src/lib/bareUrl.js b/client/src/lib/bareUrl.js index 26043f28d1..324eaf9bc5 100644 --- a/client/src/lib/bareUrl.js +++ b/client/src/lib/bareUrl.js @@ -1,77 +1,8 @@ /** - * Bare-URL detection — MIRROR of `server/lib/bareUrl.js` (authoritative there). + * The bare-URL detector shared by capture and validation. * - * The server files a capture whose entire text is a URL straight to the links - * collection. The capture boxes preview that decision (the "will be saved to - * Links" hint, and the Creative toggle it disables), so they must answer the - * question exactly the way the server will — a looser client predicate promises - * a filing the server won't perform. - * - * Port any change from the server copy verbatim; parity is enforced by - * `server/lib/bareUrl.mirror.test.js`. (Distinct from `utils/urlNormalize.js`'s - * `isUrl`, which answers a deliberately looser question for the Links quick-add.) - */ - -// Explicit http(s) scheme — the URL constructor does the real validation below. -const HTTP_SCHEME_PATTERN = /^https?:\/\//i; - -// SSH remote: git@host:owner/repo(.git) -const SSH_GIT_PATTERN = /^git@[a-z0-9.-]+:[\w.-]+\/[\w.-]+$/i; - -// Scheme-less host[:port][/path] with a plausible alphabetic TLD: -// "example.com", "sub.example.co.uk/path?q=1", "example.com:8080/x". -const DOMAIN_LIKE_PATTERN = /^(?:[a-z0-9-]+\.)+[a-z]{2,24}(?::\d{2,5})?(?:[/?#]\S*)?$/i; - -// Several ccTLDs double as common file extensions (`.md` Moldova, `.sh` St -// Helena, `.py` Paraguay…), so a scheme-less bare token like `notes.md` or -// `deploy.sh` is far more likely a filename in a note than a host. Only the -// scheme-less, path-less form is filtered — `https://foo.md` and `foo.md/page` -// still read as URLs. Digit-bearing extensions (`mp4`, `h264`) need no entry: -// DOMAIN_LIKE_PATTERN's all-alphabetic TLD already rejects them. Not exhaustive -// by construction — an extension that isn't also a plausible TLD can't reach here. -const FILE_EXTENSION_TAIL = new Set([ - 'md', 'txt', 'log', 'csv', 'json', 'xml', 'yml', 'yaml', 'toml', 'ini', 'env', 'lock', - 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'htm', 'py', 'rb', 'sh', 'zsh', 'go', 'rs', - 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'tar', 'gz', - 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'mov', 'wav' -]); - -/** - * True for a scheme-less, path-less `name.ext` whose tail is a known file - * extension (`notes.md`) rather than a host. - */ -function looksLikeFilename(token) { - if (/[/?#:]/.test(token)) return false; - const tail = token.slice(token.lastIndexOf('.') + 1).toLowerCase(); - return FILE_EXTENSION_TAIL.has(tail); -} - -/** - * If `text` is nothing but a single URL, return it normalized (an `https://` - * scheme is prepended to a bare host). Returns null for free text, multi-token - * input, a URL with surrounding prose, or a non-http(s)/git scheme. - * - * @param {string} text - * @returns {string|null} + * Re-export of `server/lib/bareUrl.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/bareUrl` import path in the client is unchanged. */ -export function parseBareUrl(text) { - const trimmed = (text ?? '').trim(); - // "Just a URL" means the whole capture is one token — any whitespace (a label, - // a trailing note, a second URL) makes it a thought that mentions a link. - if (!trimmed || /\s/.test(trimmed)) return null; - - if (SSH_GIT_PATTERN.test(trimmed)) return trimmed; - - let candidate = null; - if (HTTP_SCHEME_PATTERN.test(trimmed)) { - candidate = trimmed; - } else if (DOMAIN_LIKE_PATTERN.test(trimmed) && !looksLikeFilename(trimmed)) { - candidate = `https://${trimmed}`; - } - if (!candidate) return null; - - // Final gate: the parser rejects shapes the regexes let through (bad port, - // malformed IPv6 host). The scheme needs no re-check — a candidate only exists - // here because it matched `http(s)://` or had `https://` prepended. - return URL.canParse(candidate) ? candidate : null; -} +export { parseBareUrl } from '../../../server/lib/bareUrl.js'; diff --git a/client/src/lib/bibleLimits.js b/client/src/lib/bibleLimits.js index f328bf3b96..e45812066d 100644 --- a/client/src/lib/bibleLimits.js +++ b/client/src/lib/bibleLimits.js @@ -1,113 +1,15 @@ -// Client mirror of `server/lib/storyBible.js` `BIBLE_LIMITS`. Enforced by -// `server/lib/storyBible.test.js` "client mirror" suite — if you update one -// side, update the other and the drift test passes. -export const BIBLE_LIMITS = Object.freeze({ - NAME_MAX: 200, - ROLE_MAX: 200, - ALIAS_MAX: 100, - ALIASES_PER_ENTRY_MAX: 12, - PHYSICAL_DESCRIPTION_MAX: 2000, - PERSONALITY_MAX: 2000, - BACKGROUND_MAX: 2000, - NOTES_MAX: 4000, - IMAGE_REF_MAX: 500, - IMAGE_REFS_PER_ENTRY_MAX: 12, - PRONOUNS_MAX: 60, - AGE_MAX: 80, - CORE_THEME_MAX: 500, - SPEECH_ACCENT_MAX: 500, - SPEECH_PATTERN_MAX: 1000, - VISUAL_NOTES_MAX: 1000, - SILHOUETTE_NOTES_MAX: 2000, - POSTURE_NOTES_MAX: 1000, - SPECIAL_TRAITS_MAX: 2000, - VISUAL_IDENTITY_MAX: 1000, - MOTIVATIONS_MAX: 2000, - // Character framework (CWQE Phase 10, #2175). - GHOST_MAX: 1000, - WOUND_MAX: 1000, - LIE_MAX: 600, - WANT_MAX: 600, - NEED_MAX: 600, - SECRET_MAX: 600, - SECRETS_PER_CHARACTER_MAX: 12, - SLIDER_MIN: 1, - SLIDER_MAX: 10, - LIKES_MAX: 1500, - DISLIKES_MAX: 1500, - MANNERISMS_MAX: 1500, - RELATIONSHIPS_MAX: 2000, - RELATIONSHIP_TARGET_ID_MAX: 64, - RELATIONSHIP_TYPE_MAX: 60, - RELATIONSHIP_DESCRIPTION_MAX: 1000, - RELATIONSHIP_OPPOSITION_AXIS_MAX: 60, - RELATIONSHIP_OPPOSITION_ROLE_MAX: 120, - RELATIONSHIP_OPPOSITION_NOTE_MAX: 600, - RELATIONSHIP_LINKS_PER_CHARACTER_MAX: 40, - SKILLS_MAX: 2000, - STAT_LABEL_MAX: 80, - STAT_VALUE_MAX: 200, - STATS_PER_CHARACTER_MAX: 30, - COLOR_NAME_MAX: 80, - COLOR_HEX_MAX: 10, - COLOR_ROLE_MAX: 120, - COLORS_PER_PALETTE_MAX: 12, - PROP_NAME_MAX: 120, - PROP_PURPOSE_MAX: 400, - PROP_MATERIALS_MAX: 200, - PROP_NOTES_MAX: 600, - PROPS_PER_CHARACTER_MAX: 12, - EXPRESSION_NAME_MAX: 80, - EXPRESSION_DESC_MAX: 400, - EXPRESSIONS_PER_CHARACTER_MAX: 16, - GESTURE_NAME_MAX: 80, - GESTURE_DESC_MAX: 300, - GESTURES_PER_CHARACTER_MAX: 12, - WARDROBE_NAME_MAX: 120, - WARDROBE_DESCRIPTION_MAX: 800, - WARDROBES_PER_CHARACTER_MAX: 10, - EVIDENCE_ITEM_MAX: 500, - EVIDENCE_PER_ENTRY_MAX: 20, - SLUGLINE_MAX: 200, - PALETTE_MAX: 200, - ERA_MAX: 200, - WEATHER_MAX: 200, - RECURRING_DETAILS_MAX: 1000, - PLACE_DESCRIPTION_MAX: 2000, - OBJECT_DESCRIPTION_MAX: 2000, - SIGNIFICANCE_MAX: 1000, - ATTACHMENT_CHARACTER_ID_MAX: 64, - ATTACHMENT_EMOTION_MAX: 120, - ATTACHMENT_SIGNIFICANCE_MAX: 1000, - ATTACHMENT_ORIGIN_MAX: 1000, - ATTACHMENTS_PER_OBJECT_MAX: 40, - ENTRIES_PER_BIBLE_MAX: 200, - PROMPT_MAX: 2000, - TAG_MAX: 60, - TAGS_PER_ENTRY_MAX: 12, - SOURCE_SERIES_ID_MAX: 64, - VOICE_ID_MAX: 200, - VOICE_CANON_VERSION_MAX: 100000, - VOICE_CANON_DESCRIPTION_MAX: 1200, - VOICE_CANON_DELIVERY_MAX: 1200, - VOICE_CANON_RANGE_ITEM_MAX: 240, - VOICE_CANON_RANGE_MAX: 12, - VOICE_CANON_AVOID_ITEM_MAX: 240, - VOICE_CANON_AVOID_MAX: 12, - VOICE_CANON_PRONUNCIATION_TERM_MAX: 160, - VOICE_CANON_PRONUNCIATION_VALUE_MAX: 240, - VOICE_CANON_PRONUNCIATIONS_MAX: 24, - IDENTITY_PACK_ASSETS_MAX: 24, - IDENTITY_PACK_AVOID_ITEM_MAX: 240, - IDENTITY_PACK_AVOID_MAX: 12, - INGREDIENT_ID_MAX: 64, - // Reveal-gated canon / spoiler scoping (#2178). - SURFACE_DESCRIPTOR_MAX: 2000, - REVEAL_ISSUE_MAX: 100000, -}); +/** + * Re-export of `BIBLE_LIMITS` from the pure server leaf `server/lib/bibleLimits.js` + * (the caps every canon sanitizer measures against), plus the client-only + * `capImageRefs` / `appendImageRefById` helpers the optimistic imageRefs-append + * paths use. + */ +import { BIBLE_LIMITS } from '../../../server/lib/bibleLimits.js'; -// Client-only helper (the cap *value* IMAGE_REFS_PER_ENTRY_MAX is mirrored from -// the server; this convenience function is not). Trims an imageRefs list to that +export { BIBLE_LIMITS }; + +// Client-only helper (the cap *value* IMAGE_REFS_PER_ENTRY_MAX comes from the +// server leaf above; this convenience function is client-only). Trims an imageRefs list to that // last-N cap, mirroring the server's `appendEntryImageRef` rotation. Shared by // the optimistic imageRefs-append paths in the universe/canon render surfaces so // a local stamp never grows past what the durable server append keeps. diff --git a/client/src/lib/canonPrompt.js b/client/src/lib/canonPrompt.js index 6e4e130290..296e784d9e 100644 --- a/client/src/lib/canonPrompt.js +++ b/client/src/lib/canonPrompt.js @@ -1,209 +1,16 @@ -// Mirror of server/lib/canonPrompt.js — the SHORT_SPEC / RICH_SPEC tables -// + `shortCanonDescriptorFragments` / `richCanonDescriptorFragments` / -// `descriptorForCanonEntry` / `hasCanonDescriptorContent` helpers must -// match the server side exactly. The server-only `flatten*` exports -// (`flattenStats`/`flattenPalette`/etc.) are NOT part of the mirror — -// they're consumed by reference-sheet rendering paths that don't run in -// the client and would only bloat the bundle. Header comments differ -// intentionally; when editing the mirrored helpers, port logic changes -// verbatim and leave commentary scoped to each side. The server copy is -// authoritative; tests in server/lib/canonPrompt.test.js are the contract. - -const trim = (s) => (typeof s === 'string' ? s.trim() : ''); - -// SHORT spec: chars/objects use single-with-fallback; places uses a -// sequence so palette can carry its prefix. -const SHORT_SPEC = Object.freeze({ - characters: Object.freeze({ primary: 'physicalDescription', fallback: 'description' }), - places: Object.freeze({ - sequence: Object.freeze([ - { field: 'description' }, - { field: 'palette', prefix: 'Palette' }, - { field: 'recurringDetails' }, - ]), - }), - objects: Object.freeze({ primary: 'description', fallback: 'significance' }), -}); - -// PREVIEW spec: importer pre-commit review surface. Wider than SHORT (the -// user needs to see narrative-only fields like `personality` / `background` -// to judge "include this character?") and intentionally distinct from RICH -// (RICH drives render prompts; preview is about identity disambiguation). -// `subtitleField` is the single-line tagline; `bodyFields` uses the same -// `[{ field }]` sequence shape as RICH_SPEC so `fragmentsFromSequence` can -// be reused (no prefixes — importer cards render values verbatim). -const PREVIEW_SPEC = Object.freeze({ - characters: Object.freeze({ - subtitleField: 'role', - bodyFields: Object.freeze([ - { field: 'physicalDescription' }, - { field: 'personality' }, - { field: 'background' }, - ]), - }), - places: Object.freeze({ - subtitleField: 'slugline', - bodyFields: Object.freeze([{ field: 'description' }]), - }), - objects: Object.freeze({ - subtitleField: null, - bodyFields: Object.freeze([ - { field: 'description' }, - { field: 'significance' }, - ]), - }), -}); - -// RICH spec: ordered list of all descriptor fields. Prefixes capitalized -// uniformly so flattened output reads as natural sentence fragments. -const RICH_SPEC = Object.freeze({ - characters: Object.freeze([ - { field: 'physicalDescription' }, - { field: 'role' }, - { field: 'visualNotes' }, - { field: 'silhouetteNotes' }, - { field: 'postureNotes' }, - { field: 'specialTraits' }, - { field: 'visualIdentity' }, - ]), - places: Object.freeze([ - { field: 'description' }, - { field: 'palette', prefix: 'Palette' }, - { field: 'era', prefix: 'Era' }, - { field: 'weather', prefix: 'Weather' }, - { field: 'recurringDetails' }, - ]), - objects: Object.freeze([ - { field: 'description' }, - { field: 'significance', prefix: 'Significance' }, - ]), -}); - -function normalizeKind(kind) { - const k = String(kind || '').toLowerCase(); - if (k === 'character' || k === 'characters') return 'characters'; - if (k === 'place' || k === 'places') return 'places'; - if (k === 'object' || k === 'objects') return 'objects'; - return null; -} - -function fragmentsFromSequence(sequence, entry) { - const out = []; - for (const spec of sequence) { - const value = trim(entry[spec.field]); - if (!value) continue; - out.push(spec.prefix ? { field: spec.field, value, prefix: spec.prefix } : { field: spec.field, value }); - } - return out; -} - -// Short-circuit "any non-blank field in this sequence" — used by -// `hasCanonDescriptorContent` so per-entry render-count filters don't -// allocate a full fragments array just to read `.length > 0`. -function sequenceHasAnyField(sequence, entry) { - for (const spec of sequence) { - if (trim(entry[spec.field])) return true; - } - return false; -} - /** - * SHORT descriptor fragments — the visual subset used in canon UI cards - * and the render-ref button-enable predicate. + * SHORT/RICH/PREVIEW canon descriptor spec and its fragment renderers. * - * Returns `[{ field, value, prefix? }]` in display order. Empty/missing - * fields produce no fragment. For chars/objects this is at most a single - * fragment (primary with single-field fallback). + * Re-export of `server/lib/canonPrompt.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/canonPrompt` import path in the client is unchanged. */ -export function shortCanonDescriptorFragments(kind, entry) { - if (!entry || typeof entry !== 'object') return []; - const spec = SHORT_SPEC[normalizeKind(kind)]; - if (!spec) return []; - if (spec.sequence) return fragmentsFromSequence(spec.sequence, entry); - const primary = trim(entry[spec.primary]); - if (primary) return [{ field: spec.primary, value: primary }]; - const fallback = trim(entry[spec.fallback]); - if (fallback) return [{ field: spec.fallback, value: fallback }]; - return []; -} - -/** - * RICH descriptor fragments — every descriptive field that contributes to - * a render prompt body. Used by render-synthesis and the - * "has any content?" gate. - */ -export function richCanonDescriptorFragments(kind, entry) { - if (!entry || typeof entry !== 'object') return []; - const sequence = RICH_SPEC[normalizeKind(kind)]; - if (!sequence) return []; - return fragmentsFromSequence(sequence, entry); -} - -/** - * Render `[{ prefix?, value }]` fragments as an array of display strings. - * Mirror of server `mapCanonDescriptorFragments`. See server docstring. - */ -export function mapCanonDescriptorFragments(fragments, { trailingPeriod = false } = {}) { - if (!Array.isArray(fragments)) return []; - return fragments.map((f) => { - if (!f) return ''; - const body = f.prefix ? `${f.prefix}: ${f.value}` : (f.value ?? ''); - return f.prefix && trailingPeriod ? `${body}.` : body; - }); -} - -/** - * Flatten `[{ prefix?, value }]` fragments to a single sentence-style string. - * Mirror of server `flattenCanonDescriptorFragments`. See server docstring. - */ -export function flattenCanonDescriptorFragments(fragments, { separator = '. ', trailingPeriod = false } = {}) { - return mapCanonDescriptorFragments(fragments, { trailingPeriod }).join(separator); -} - -/** - * Flatten SHORT fragments into a sentence-style descriptor string. - * Matches the legacy `KINDS[].descFor` output: - * characters: "physicalDescription" else "description" - * settings: "description. Palette: . recurringDetails" - * objects: "description" else "significance" - */ -export function descriptorForCanonEntry(kind, entry) { - return flattenCanonDescriptorFragments(shortCanonDescriptorFragments(kind, entry)); -} - -/** - * PREVIEW fragments — importer pre-commit review surface. - * - * Returns `{ subtitle, body }` where `subtitle` is a single trimmed string - * (empty when the kind has no subtitle field or the value is blank) and - * `body` is an ordered `[{ field, value }]` array of non-blank fields, - * intended for ` • `-joined rendering in importer cards. Unknown kinds and - * non-object entries return the empty shape so callers can render - * unconditionally. - * - * This is intentionally wider than `shortCanonDescriptorFragments` (which - * is scoped to the visual subset that drives render-prompts and ref-image - * gating). PREVIEW exists so the user can disambiguate which character / - * place / object to commit, so it surfaces narrative-only fields - * (`personality`, `background`, `slugline`) that have no visual role. - */ -export function previewCanonFragments(kind, entry) { - if (!entry || typeof entry !== 'object') return { subtitle: '', body: [] }; - const spec = PREVIEW_SPEC[normalizeKind(kind)]; - if (!spec) return { subtitle: '', body: [] }; - const subtitle = spec.subtitleField ? trim(entry[spec.subtitleField]) : ''; - return { subtitle, body: fragmentsFromSequence(spec.bodyFields, entry) }; -} - -/** - * True when the entry has any non-blank value across the RICH field set. - * Mirrors `canonEntryHasContent`'s per-kind union check (UniverseBuilder.jsx) - * and is the read-side mirror of `synthesizeCanonPrompt`'s skip-empty-seed - * rule. - */ -export function hasCanonDescriptorContent(kind, entry) { - if (!entry || typeof entry !== 'object') return false; - const sequence = RICH_SPEC[normalizeKind(kind)]; - if (!sequence) return false; - return sequenceHasAnyField(sequence, entry); -} +export { + descriptorForCanonEntry, + flattenCanonDescriptorFragments, + hasCanonDescriptorContent, + mapCanonDescriptorFragments, + previewCanonFragments, + richCanonDescriptorFragments, + shortCanonDescriptorFragments, +} from '../../../server/lib/canonPrompt.js'; diff --git a/client/src/lib/catalogTypes.js b/client/src/lib/catalogTypes.js index 5fac0aac20..3141a51fea 100644 --- a/client/src/lib/catalogTypes.js +++ b/client/src/lib/catalogTypes.js @@ -1,19 +1,35 @@ /** - * Client mirror of `server/lib/catalogTypes.js` — the catalog ingredient type - * registry. The server registry carries extra server-only fields (idPrefix, - * ftsFields, payloadSchemaVersion, payloadUpgraders); the client mirror keeps - * only what the UI renders: label, badge color, the inline-form primary - * content key/label, the snippet fallback chain, and the per-type editor field - * list. + * The catalog ingredient type registry as the UI consumes it — the server + * registry in `server/lib/catalogTypes.js` plus the per-type editor layout. * - * Drift is asserted by `server/lib/catalogTypes.parity.test.js` against the - * server registry's shared fields — if you change one side, change the other. + * The registry itself is NOT copied. `CATALOG_TYPES` below is projected from + * the server entries, so `label` / `badgeColor` / the primary-content key and + * label / the snippet fallback chain / `editableListFields` (and its + * `BIBLE_LIMITS`-derived caps) have exactly one definition and cannot drift. + * `RELATION_KINDS`, `MEDIA_KINDS`, `canonicalTagKey`, `payloadSnippet` and + * `USER_TYPE_FIELD_KINDS` are re-exported outright. * - * Adding a type: add an entry here AND in the server registry (+ one migration - * loosening the CHECK constraint). The Catalog list/picker/inline-form, the - * detail editor, and the type chips all map over `CATALOG_TYPES` so the new - * type surfaces everywhere without a per-surface edit. + * What stays here is presentation the server has no use for: the grouped + * "character sheet" `editorSections` and their flattened `editorFields`, the + * read-only `CHARACTER_LIST_FIELDS`, badge/ref-role lookups, and the + * user-defined-type normalization the `useCatalogTypes` hook merges in. + * + * Adding a type: add it to the SERVER registry (+ one migration loosening the + * CHECK constraint), then give it an editor layout in `TYPE_EDITOR_LAYOUT` + * below. The Catalog list/picker/inline-form, the detail editor, and the type + * chips all map over `CATALOG_TYPES`, so it surfaces everywhere at once. */ +import { CATALOG_TYPES as SERVER_CATALOG_TYPES } from '../../../server/lib/catalogTypes.js'; + +export { + MEDIA_KINDS, + RELATION_KINDS, + USER_TYPE_FIELD_KINDS, + canonicalTagKey, + getMediaKind, + getRelationKind, + payloadSnippet, +} from '../../../server/lib/catalogTypes.js'; // Per-type detail-editor field list. Each entry is `[key, label, kind]` where // `kind` is 'text' (single line) or 'textarea' (multi-line). The light types @@ -159,151 +175,36 @@ export const CHARACTER_LIST_FIELDS = Object.freeze([ { key: 'stats', label: 'Stats', kind: 'kv' }, ]); -// Structured array-field editors for the bible types — client MIRROR of the -// server `editableListFields` (server/lib/catalogTypes.js). The Catalog detail -// editor renders these as inline structured editors (AliasListEditor / -// ColorPaletteEditor / StatListEditor) instead of read-only chips. The numeric -// caps are hardcoded BIBLE_LIMITS values (the client can't import storyBible) — -// the parity test asserts they match the server's BIBLE_LIMITS-sourced values -// EXACTLY, so a limit bump on the server fails CI here until it's mirrored. -// ALIAS_MAX=100, ALIASES_PER_ENTRY_MAX=12, COLOR_NAME_MAX=80, -// COLORS_PER_PALETTE_MAX=12, STAT_VALUE_MAX=200, STATS_PER_CHARACTER_MAX=30 -const CHARACTER_EDITABLE_LIST_FIELDS = [ - { key: 'aliases', label: 'Aliases', kind: 'stringArray', itemMax: 100, listMax: 12 }, - { key: 'colorPalette', label: 'Color Palette', kind: 'colorPalette', itemMax: 80, listMax: 12 }, - { key: 'stats', label: 'Stats', kind: 'kv', itemMax: 200, listMax: 30 }, -]; -const PLACE_EDITABLE_LIST_FIELDS = []; -const OBJECT_EDITABLE_LIST_FIELDS = [ - { key: 'aliases', label: 'Aliases', kind: 'stringArray', itemMax: 100, listMax: 12 }, -]; +// Per-type editor layout — the one thing the server registry has no use for. +// `editorSections` is the grouped "character sheet" view the detail editor +// renders; `editorFields` stays the flat enumeration the revision-diff builder +// and every "each editable scalar key" consumer reads. The keys mirror the canon +// sanitizers in `server/lib/storyBible.js` (`sanitizeCharacter` / +// `sanitizePlace` / `sanitizeObject`) EXACTLY, so a Catalog-surface edit lands +// in the same payload field the Universe Builder canon surface reads. +const TYPE_EDITOR_LAYOUT = { + character: { editorSections: CHARACTER_SECTIONS, editorFields: flattenSections(CHARACTER_SECTIONS) }, + place: { editorSections: PLACE_SECTIONS, editorFields: flattenSections(PLACE_SECTIONS) }, + object: { editorSections: OBJECT_SECTIONS, editorFields: flattenSections(OBJECT_SECTIONS) }, + idea: { editorFields: LIGHT_FIELDS }, + scene: { editorFields: LIGHT_FIELDS }, + concept: { editorFields: LIGHT_FIELDS }, +}; -export const CATALOG_TYPES = Object.freeze([ - { - id: 'character', - label: 'Character', - badgeColor: 'bg-blue-500/20 text-blue-300 border-blue-500/40', - primaryContentKey: 'physicalDescription', - primaryContentLabel: 'Physical Description', - snippetFallbackKeys: ['physicalDescription', 'description', 'summary', 'personality', 'significance', 'role', 'notes'], - // Grouped DnD-style sheet sections (rendered by CatalogIngredient). Keys - // mirror `sanitizeCharacter` in server/lib/storyBible.js EXACTLY so a - // Catalog-surface edit lands in the same canon field the Universe Builder - // reads. `editorFields` is the flattened enumeration of these same keys. - editorSections: CHARACTER_SECTIONS, - editorFields: flattenSections(CHARACTER_SECTIONS), - editableListFields: CHARACTER_EDITABLE_LIST_FIELDS, - }, - { - id: 'place', - label: 'Place', - badgeColor: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40', - primaryContentKey: 'description', - primaryContentLabel: 'Description', - snippetFallbackKeys: ['description', 'summary', 'significance', 'notes'], - editorSections: PLACE_SECTIONS, - editorFields: flattenSections(PLACE_SECTIONS), - editableListFields: PLACE_EDITABLE_LIST_FIELDS, - }, - { - id: 'object', - label: 'Object', - badgeColor: 'bg-amber-500/20 text-amber-300 border-amber-500/40', - primaryContentKey: 'description', - primaryContentLabel: 'Description', - snippetFallbackKeys: ['description', 'significance', 'summary', 'notes'], - editorSections: OBJECT_SECTIONS, - editorFields: flattenSections(OBJECT_SECTIONS), - editableListFields: OBJECT_EDITABLE_LIST_FIELDS, - }, - { - id: 'idea', - label: 'Idea', - badgeColor: 'bg-purple-500/20 text-purple-300 border-purple-500/40', - primaryContentKey: 'summary', - primaryContentLabel: 'Summary', - snippetFallbackKeys: ['summary', 'description', 'notes'], - editorFields: LIGHT_FIELDS, - }, - { - id: 'scene', - label: 'Scene', - badgeColor: 'bg-pink-500/20 text-pink-300 border-pink-500/40', - primaryContentKey: 'summary', - primaryContentLabel: 'Summary', - snippetFallbackKeys: ['summary', 'description', 'notes'], - editorFields: LIGHT_FIELDS, - }, - { - id: 'concept', - label: 'Concept', - badgeColor: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40', - primaryContentKey: 'summary', - primaryContentLabel: 'Summary', - snippetFallbackKeys: ['summary', 'description', 'notes'], - editorFields: LIGHT_FIELDS, - }, -]); - -/** - * Catalog ingredient↔ingredient RELATION kinds — client mirror of - * `server/lib/catalogTypes.js` RELATION_KINDS. Drives the "Relations" panel - * picker on the ingredient detail page. `label` is the from→to direction; - * `inverseLabel` renders the same stored edge from the `to` side. - * - * Drift is asserted by `server/lib/catalogTypes.parity.test.js` against the server - * registry — change one side, change the other. - */ -export const RELATION_KINDS = Object.freeze([ - { id: 'appears-in', label: 'Appears in', inverseLabel: 'Features' }, - { id: 'lives-in', label: 'Lives in', inverseLabel: 'Home of' }, - { id: 'created-by', label: 'Created by', inverseLabel: 'Creator of' }, - { id: 'parent-of', label: 'Parent of', inverseLabel: 'Child of' }, - { id: 'variant-of', label: 'Variant of', inverseLabel: 'Has variant' }, - { id: 'references', label: 'References', inverseLabel: 'Referenced by' }, - { id: 'related-to', label: 'Related to', inverseLabel: 'Related to' }, -]); - -/** - * Canonical key for a freeform tag label — client mirror of - * `server/lib/catalogTypes.js` `canonicalTagKey`. Lowercase + trim + collapse - * internal whitespace. Used by the tag picker to dedup the chosen-tags set - * (so `Noir` and `noir` don't both show as chips before save). Returns `''` - * for empty/non-string input. - */ -export function canonicalTagKey(label) { - if (typeof label !== 'string') return ''; - return label.trim().replace(/\s+/g, ' ').toLowerCase(); -} - -const RELATION_BY_ID = Object.freeze(Object.fromEntries(RELATION_KINDS.map((r) => [r.id, r]))); - -/** Look up a relation-kind entry by id. Returns `undefined` for unknown ids. */ -export function getRelationKind(id) { - return RELATION_BY_ID[id]; -} - -/** - * Catalog ingredient MEDIA-attachment kinds — client mirror of - * `server/lib/catalogTypes.js` MEDIA_KINDS. Drives the "Media" panel attach - * picker / drag-drop on the ingredient detail page. `accept` is the file-input - * MIME filter. Drift is asserted by `server/lib/catalogTypes.parity.test.js` - * against the server registry — change one side, change the other. - */ -export const MEDIA_KINDS = Object.freeze([ - { id: 'portrait', label: 'Portrait', accept: 'image/*' }, - { id: 'reference', label: 'Reference', accept: 'image/*' }, - { id: 'audio', label: 'Audio', accept: 'audio/*' }, - { id: 'video', label: 'Video', accept: 'video/*' }, - { id: 'document', label: 'Document', accept: '.pdf,.txt,.md' }, -]); - -const MEDIA_BY_ID = Object.freeze(Object.fromEntries(MEDIA_KINDS.map((m) => [m.id, m]))); - -/** Look up a media-kind entry by id. Returns `undefined` for unknown ids. */ -export function getMediaKind(id) { - return MEDIA_BY_ID[id]; -} +// The UI projection of the server registry: only the fields a component reads, +// plus the layout above. Server-only concerns (idPrefix, ftsFields, +// extractionShape, payloadSchemaVersion, payloadUpgraders, defaultTags) stay off +// the client entries so a component can't start depending on one. +export const CATALOG_TYPES = Object.freeze(SERVER_CATALOG_TYPES.map((type) => Object.freeze({ + id: type.id, + label: type.label, + badgeColor: type.badgeColor, + primaryContentKey: type.primaryContentKey, + primaryContentLabel: type.primaryContentLabel, + snippetFallbackKeys: type.snippetFallbackKeys, + ...(type.editableListFields ? { editableListFields: type.editableListFields } : {}), + ...(TYPE_EDITOR_LAYOUT[type.id] || { editorFields: LIGHT_FIELDS }), +}))); const BY_ID = Object.freeze(Object.fromEntries(CATALOG_TYPES.map((t) => [t.id, t]))); @@ -333,42 +234,6 @@ const CATALOG_REF_ROLE_BY_TYPE = Object.freeze({ }); export const catalogRefRoleForType = (type) => CATALOG_REF_ROLE_BY_TYPE[type] || 'reference'; -/** - * Pull a short snippet from a payload using a type's fallback chain (first - * non-empty key wins), trimmed + ellipsised to `max` chars. When `typeId` is - * unknown/absent, falls back to a broad union of every type's keys so a row of - * unknown type still renders a snippet. - * - * `resolveType` (optional) lets a caller pass the MERGED registry resolver - * (`useCatalogTypes().getType`) so a user-defined type's custom - * `snippetFallbackKeys` (e.g. a `creed` key) are honored — the static `BY_ID` - * only knows the built-ins. Falls back to the static lookup when omitted. - */ -export function payloadSnippet(payload, typeId, max = 120, resolveType = null) { - if (!payload || typeof payload !== 'object') return ''; - const typeDef = (resolveType && resolveType(typeId)) || BY_ID[typeId]; - const keys = typeDef?.snippetFallbackKeys || UNION_SNIPPET_KEYS; - let raw = ''; - for (const k of keys) { - if (payload[k]) { raw = payload[k]; break; } - } - const text = String(raw).trim().replace(/\s+/g, ' '); - if (text.length <= max) return text; - return `${text.slice(0, max - 3)}…`; -} - -// Ordered union of every type's snippet keys — used as the unknown-type -// fallback so a row whose type isn't in the registry still gets a snippet. -const UNION_SNIPPET_KEYS = (() => { - const out = []; - for (const t of CATALOG_TYPES) { - for (const k of t.snippetFallbackKeys) { - if (!out.includes(k)) out.push(k); - } - } - return out; -})(); - // --- User-defined types (client mirror) ---------------------------------- // User types are defined in Settings → Catalog, persisted server-side in // settings.json, and served (merged with the system registry) via @@ -377,13 +242,6 @@ const UNION_SNIPPET_KEYS = (() => { // them up. The static registry stays the synchronous fallback so first render // never blanks. -/** Field kinds a user type may declare — mirror of the server constant. */ -export const USER_TYPE_FIELD_KINDS = Object.freeze(['string', 'longtext', 'tags', 'ref']); - -// Map a server field `kind` to the client editor widget kind. `string` → a -// single-line input ('text'), `longtext` → a textarea, `tags`/`ref` keep their -// names (the generic renderer special-cases them). Unknown kinds fall back to -// 'text' so a forked-peer field never crashes the renderer. const FIELD_KIND_TO_WIDGET = { string: 'text', longtext: 'textarea', tags: 'tags', ref: 'ref' }; /** diff --git a/client/src/lib/extensionErrors.js b/client/src/lib/extensionErrors.js index cc6fcdd7c5..a63e62bfa9 100644 --- a/client/src/lib/extensionErrors.js +++ b/client/src/lib/extensionErrors.js @@ -1,46 +1,8 @@ /** - * Browser-extension error detection — client mirror of - * server/lib/extensionErrors.js, which is authoritative and carries the - * rationale (why provenance-first, why the message list stays short, why both - * ends filter). Port logic changes verbatim; parity is enforced by - * server/lib/extensionErrors.mirror.test.js. + * Browser-extension error classification. + * + * Re-export of `server/lib/extensionErrors.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/extensionErrors` import path in the client is unchanged. */ - -// NOTE: no `g` flag — `.test()` on a /g/ regex is stateful via lastIndex. -const EXTENSION_SCHEME_RE = /(?:chrome-extension|moz-extension|safari-extension|safari-web-extension|ms-browser-extension|opera-extension|webkit-masked-url):\/\/|\bextensions::/i; - -const EXTENSION_MESSAGE_RE = [ - /\bMetaMask\b/i, -]; - -// Only the throw site proves provenance — an extension that wraps or invokes -// our code leaves its frames below ours. Handles V8 (`at fn (url:1:1)`) and -// Firefox/Safari (`fn@url:1:1`) stacks. -function originatingFrame(stack) { - for (const raw of stack.split('\n')) { - const line = raw.trim(); - if (!line) continue; - if (/^at\s/.test(line) || line.includes('@')) return line; - } - return ''; -} - -/** - * True when an error report originated from a browser extension rather than - * PortOS itself. Checks `source` / the stack's originating frame / `message`; - * never `url` (the page location is ours even when an extension throws on it). - */ -export function isExtensionError(payload) { - if (!payload || typeof payload !== 'object') return false; - - const str = (v) => (typeof v === 'string' ? v : ''); - const source = str(payload.source); - const stack = str(payload.stack); - const message = str(payload.message); - - if (EXTENSION_SCHEME_RE.test(source)) return true; - if (EXTENSION_SCHEME_RE.test(originatingFrame(stack))) return true; - if (EXTENSION_SCHEME_RE.test(message)) return true; - - return EXTENSION_MESSAGE_RE.some(re => re.test(message)); -} +export { isExtensionError } from '../../../server/lib/extensionErrors.js'; diff --git a/client/src/lib/goalFeatureMap.js b/client/src/lib/goalFeatureMap.js index 82be7704de..d129933dca 100644 --- a/client/src/lib/goalFeatureMap.js +++ b/client/src/lib/goalFeatureMap.js @@ -1,68 +1,13 @@ -// Goal → PortOS feature-area map (issue #2666). -// -// Deterministic, LLM-free registry that turns a goal's `category` (or its -// optional per-goal `featureAreas` override) into the concrete PortOS feature -// that actually moves it forward, each carrying a label, an icon name, and a -// deep-link. Every `to` path MUST be an existing route registered in -// `server/lib/navManifest.js` (`NAV_COMMANDS`) so deep-links can't drift — this -// is enforced by `server/lib/goalFeatureMap.test.js`. -// -// MIRROR: this file is kept byte-for-byte in sync with -// `server/lib/goalFeatureMap.js` (the server uses it to validate the per-goal -// `featureAreas` override and to build the same rows server-side if needed). -// `icon` is a lucide-react icon NAME (string) so this module stays React-free -// and importable from server-side tests; the widget resolves the name to a -// component at render time. - -// Feature areas, keyed by a stable area id. Each `to` is a live NAV_COMMANDS path. -export const FEATURE_AREAS = { - post: { label: 'Daily POST', to: '/post/launcher', icon: 'Brain', feature: 'post' }, - bodyHealth: { label: 'Body Health', to: '/meatspace/health', icon: 'HeartPulse' }, - writersRoom: { label: 'Writers Room', to: '/writers-room', icon: 'PenLine' }, - universes: { label: 'Universes', to: '/universes', icon: 'Globe' }, - pipeline: { label: 'Series Pipeline', to: '/pipeline', icon: 'Clapperboard' }, - tribe: { label: 'Tribe', to: '/tribe', icon: 'Users' }, - autobiography: { label: 'Autobiography', to: '/digital-twin/autobiography', icon: 'BookOpen' }, - legacyBundle: { label: 'Legacy Bundle', to: '/digital-twin/legacy', icon: 'Package' }, - sharing: { label: 'Sharing', to: '/sharing', icon: 'Share2' }, - planMilestones:{ label: 'Plan Milestones', to: '/goals/tree', icon: 'ListTree' }, - memory: { label: 'Memory', to: '/brain/memory', icon: 'BrainCircuit' }, -}; - -// Every valid area id — the source of truth for the per-goal override enum. -export const FEATURE_AREA_IDS = Object.keys(FEATURE_AREAS); - -// Curated category → ordered feature-area ids. A goal with no override falls -// back to its category's default; an unknown category resolves to an empty list. -export const GOAL_CATEGORY_FEATURE_MAP = { - creative: ['writersRoom', 'universes', 'pipeline'], - family: ['tribe'], - health: ['post', 'bodyHealth'], - financial: ['planMilestones'], - legacy: ['autobiography', 'legacyBundle', 'sharing'], - mastery: ['post', 'memory'], -}; - -// Resolve the feature-area rows for a goal. Honors the optional per-goal -// `featureAreas` override (an ordered array of area ids) when present and -// non-empty — filtering out any unknown ids — otherwise falls back to the -// category default. When supplied, isFeatureEnabled filters gated rows while -// preserving the selected/default order. Returns rows with an optional feature tag. -export function getGoalFeatureAreas(goal, isFeatureEnabled) { - const override = Array.isArray(goal?.featureAreas) - ? goal.featureAreas.filter((id) => FEATURE_AREAS[id]) - : []; - const categoryDefaults = GOAL_CATEGORY_FEATURE_MAP[goal?.category] || []; - const areaIds = override.length > 0 - ? override - : categoryDefaults; - const rows = areaIds.map((area) => ({ area, ...FEATURE_AREAS[area] })); - if (typeof isFeatureEnabled !== 'function') return rows; - - const enabledRows = rows.filter((row) => isFeatureEnabled(row.feature)); - if (enabledRows.length > 0 || override.length === 0) return enabledRows; - - return categoryDefaults - .map((area) => ({ area, ...FEATURE_AREAS[area] })) - .filter((row) => isFeatureEnabled(row.feature)); -} +/** + * Goal → instance-feature mapping. + * + * Re-export of `server/lib/goalFeatureMap.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/goalFeatureMap` import path in the client is unchanged. + */ +export { + FEATURE_AREAS, + FEATURE_AREA_IDS, + GOAL_CATEGORY_FEATURE_MAP, + getGoalFeatureAreas, +} from '../../../server/lib/goalFeatureMap.js'; diff --git a/client/src/lib/grokVideoClip.js b/client/src/lib/grokVideoClip.js index bff1508b87..bd29b44c47 100644 --- a/client/src/lib/grokVideoClip.js +++ b/client/src/lib/grokVideoClip.js @@ -1,11 +1,8 @@ -// Client mirror of `server/lib/grokVideoClip.js` — the clip lengths grok's -// `image_to_video` tool actually delivers (measured, #3022; see that file for -// the evidence). Enforced by the "client mirror" suite in -// `server/lib/grokVideoClip.test.js`: if this drifts from the server list, that -// test fails. Update the SERVER file first, then this one. - -/** Clip lengths (seconds) grok's image_to_video delivers, ascending. */ -export const GROK_VIDEO_DURATIONS = [6, 10]; - -/** Fallback when no clip length is chosen. */ -export const GROK_VIDEO_DEFAULT_DURATION = 6; +/** + * The clip lengths grok’s image_to_video actually delivers. + * + * Re-export of `server/lib/grokVideoClip.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/grokVideoClip` import path in the client is unchanged. + */ +export { GROK_VIDEO_DEFAULT_DURATION, GROK_VIDEO_DURATIONS } from '../../../server/lib/grokVideoClip.js'; diff --git a/client/src/lib/isSafeHref.js b/client/src/lib/isSafeHref.js index cceb97172a..ec0dbc91c7 100644 --- a/client/src/lib/isSafeHref.js +++ b/client/src/lib/isSafeHref.js @@ -1,25 +1,8 @@ /** - * Pure http(s)-only scheme check for user-supplied URL fields that get - * rendered as a clickable `` (privacy-org website/portal links, - * broker opt-out/search URLs, song reference links, notification links, …). + * The one rule for whether a user-supplied href may be rendered as a link. * - * A stored `javascript:`/`data:`/`vbscript:` URL turns into a stored-XSS - * payload the moment it's rendered as an href — validating the scheme at - * write time (server, Zod `.refine`) and re-checking at render time - * (client) closes both the write and the read side. Mirrors - * `server/lib/isSafeHref.js` (kept as two small copies — server and client - * don't share a build step — so keep both in sync if this changes; pinned - * by `isSafeHref.mirror.test.js`). - * - * @param {string} url - * @returns {boolean} + * Re-export of `server/lib/isSafeHref.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/isSafeHref` import path in the client is unchanged. */ -export function isSafeHref(url) { - if (typeof url !== 'string' || url.length === 0) return false; - try { - const parsed = new URL(url); - return parsed.protocol === 'http:' || parsed.protocol === 'https:'; - } catch { - return false; - } -} +export { isSafeHref } from '../../../server/lib/isSafeHref.js'; diff --git a/client/src/lib/isSafeHref.mirror.test.js b/client/src/lib/isSafeHref.mirror.test.js deleted file mode 100644 index 0480f83f1e..0000000000 --- a/client/src/lib/isSafeHref.mirror.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { isSafeHref as clientIsSafeHref } from './isSafeHref.js'; -import { isSafeHref as serverIsSafeHref } from '../../../server/lib/isSafeHref.js'; - -// Shared input matrix so the client and server copies must agree on every -// case, not merely both exist (server/lib/mirrorCoverage.test.js's parity -// guard only proves a test reads both files). -const CASES = [ - 'https://host/path', - 'http://host/path', - 'https:foo', - 'https:/host', - 'https:///host', - '//host', - 'javascript:alert(1)', - 'data:text/html,', - '', - null, - undefined, -]; - -describe('isSafeHref client/server parity', () => { - it.each(CASES)('agrees on %p', (input) => { - expect(clientIsSafeHref(input)).toBe(serverIsSafeHref(input)); - }); -}); diff --git a/client/src/lib/issueLength.js b/client/src/lib/issueLength.js index 672858d765..ae0dba9d65 100644 --- a/client/src/lib/issueLength.js +++ b/client/src/lib/issueLength.js @@ -1,47 +1,24 @@ /** - * Client-side mirror of `server/lib/issueLength.js` — kept in lock-step - * with the server table. The client only needs the labels + headline - * numbers (pages / minutes) for the header dropdown; the full prose-word - * / beat-count derivation lives server-side where the prompts render. + * The Length Profile picker's client-side helpers, over the issue-length + * vocabulary in `server/lib/issueLength.js`. + * + * The profile table and the custom-override bounds are re-exported from the + * server leaf rather than copied: the same profile drives the picker chip and + * the server-side target computation, so a bound changed on one side cannot + * leave the form offering a value the server clamps. `clampInt` and + * `summarizeLengthProfile` are client-only form/display helpers with no server + * twin (the server's private clamp has a different empty-input contract). */ +import { DEFAULT_LENGTH_PROFILE, LENGTH_PROFILES } from '../../../server/lib/issueLength.js'; -export const LENGTH_PROFILES = Object.freeze({ - teaser: Object.freeze({ - label: 'Teaser', - description: 'Short promo issue / web teaser.', - pageTarget: 8, - minutesTarget: 10, - }), - standard: Object.freeze({ - label: 'Standard', - description: 'Standard floppy / half-hour episode (default).', - pageTarget: 22, - minutesTarget: 24, - }), - extended: Object.freeze({ - label: 'Extended', - description: 'Premiere / longer special.', - pageTarget: 32, - minutesTarget: 36, - }), - finale: Object.freeze({ - label: 'Finale', - description: 'Season / series finale or annual.', - pageTarget: 44, - minutesTarget: 48, - }), -}); - -export const DEFAULT_LENGTH_PROFILE = 'standard'; - -// Working bounds for custom overrides — mirrored from `server/lib/issueLength.js` -// (CUSTOM_PAGE_MIN / CUSTOM_PAGE_MAX / CUSTOM_MINUTE_MIN / CUSTOM_MINUTE_MAX). -// The client cannot import from the server, so these values are duplicated here -// manually. If you change the range on the server side, update this file too. -export const CUSTOM_PAGE_MIN = 4; -export const CUSTOM_PAGE_MAX = 120; -export const CUSTOM_MINUTE_MIN = 4; -export const CUSTOM_MINUTE_MAX = 240; +export { + CUSTOM_MINUTE_MAX, + CUSTOM_MINUTE_MIN, + CUSTOM_PAGE_MAX, + CUSTOM_PAGE_MIN, + DEFAULT_LENGTH_PROFILE, + LENGTH_PROFILES, +} from '../../../server/lib/issueLength.js'; // Clamp + round + fallback. Returns `null` for non-finite input so callers // can distinguish "user cleared the field" from "user typed nonsense". diff --git a/client/src/lib/letteringDensity.js b/client/src/lib/letteringDensity.js index 382034fb29..b5e57e9a94 100644 --- a/client/src/lib/letteringDensity.js +++ b/client/src/lib/letteringDensity.js @@ -1,189 +1,16 @@ -// Mirror of server/lib/editorial/letteringDensity.js (#1313) — the comic -// lettering-density accounting (`countWords`, `panelLetteringMetrics`, -// `analyzeComicLettering`, `overflowSeverity`, `sanitizeLetteringThresholds`, -// `DEFAULT_LETTERING_THRESHOLDS`) must match the server side exactly. The server -// editorial check (`comic.lettering-density`) is authoritative and tested in -// server/lib/editorial/letteringDensity.test.js; this copy powers the comic-script -// stage's INLINE per-page warnings so the author sees over-stuffed panels while -// editing — without a round-trip through an editorial-checks run. Port any logic -// change to both sides verbatim; commentary is scoped per side. -// -// A "balloon" is a discrete lettering element: each dialogue entry is one, and -// each caption box is one (the parser folds repeated captions into one -// newline-joined string, so each non-empty line counts). SFX counts toward the -// panel/page WORD load but is not a balloon. - -import { countWords } from '../utils/formatters.js'; - -export const DEFAULT_LETTERING_THRESHOLDS = Object.freeze({ - maxWordsPerBalloon: 25, - maxWordsPerPanel: 50, - maxBalloonsPerPanel: 3, - maxWordsPerPage: 150, -}); - -const LETTERING_SEVERITIES = ['high', 'medium', 'low']; - -export { countWords }; - -// Severity scaled by how far over the threshold a count runs: ≥2× → high, ≥1.4× -// → medium, else low. -export function overflowSeverity(count, threshold) { - if (!(threshold > 0)) return 'medium'; - const ratio = count / threshold; - if (ratio >= 2) return 'high'; - if (ratio >= 1.4) return 'medium'; - return 'low'; -} - -// Merge a (possibly partial) config over the defaults, guarding each field. A -// non-positive threshold falls back to the default. -export function sanitizeLetteringThresholds(config) { - const c = config && typeof config === 'object' ? config : {}; - const pick = (key) => { - const v = c[key]; - if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v; - return DEFAULT_LETTERING_THRESHOLDS[key]; - }; - return { - maxWordsPerBalloon: pick('maxWordsPerBalloon'), - maxWordsPerPanel: pick('maxWordsPerPanel'), - maxBalloonsPerPanel: pick('maxBalloonsPerPanel'), - maxWordsPerPage: pick('maxWordsPerPage'), - }; -} - -// Per-panel lettering metrics from a parsed panel -// (`{ description, caption, dialogue: [{ character, line }], sfx }`). -export function panelLetteringMetrics(panel) { - const dialogue = Array.isArray(panel?.dialogue) ? panel.dialogue : []; - const balloons = dialogue.map((d) => { - const line = typeof d?.line === 'string' ? d.line : ''; - return { - speaker: typeof d?.character === 'string' ? d.character.trim() : '', - words: countWords(line), - line, - }; - }); - const caption = typeof panel?.caption === 'string' ? panel.caption : ''; - const captionBoxes = caption - .split('\n') - .map((l) => l.trim()) - .filter(Boolean) - .map((text) => ({ words: countWords(text), text })); - const sfx = typeof panel?.sfx === 'string' ? panel.sfx : ''; - const dialogueWords = balloons.reduce((sum, b) => sum + b.words, 0); - const captionWords = captionBoxes.reduce((sum, b) => sum + b.words, 0); - const sfxWords = countWords(sfx); - return { - balloons, - captionBoxes, - balloonCount: balloons.length + captionBoxes.length, - dialogueWords, - captionWords, - sfxWords, - totalWords: dialogueWords + captionWords + sfxWords, - }; -} - -function firstLetteringText(metrics) { - const balloon = metrics.balloons.find((b) => b.line.trim()); - if (balloon) return balloon.line.trim(); - const box = metrics.captionBoxes.find((b) => b.text); - return box ? box.text : ''; -} - -// Analyze the parsed pages of ONE comic script for lettering overflows. Returns -// a flat list of violations: { kind, pageNumber, panelNumber?, balloonIndex?, -// speaker?, count, threshold, limitLabel, severity, anchorQuote }. -export function analyzeComicLettering(pages, config) { - const t = sanitizeLetteringThresholds(config); - const list = Array.isArray(pages) ? pages : []; - const violations = []; - list.forEach((page, pageIdx) => { - const pageNumber = pageIdx + 1; - const panels = Array.isArray(page?.panels) ? page.panels : []; - let pageWords = 0; - panels.forEach((panel, panelIdx) => { - const panelNumber = panelIdx + 1; - const m = panelLetteringMetrics(panel); - pageWords += m.totalWords; - - m.balloons.forEach((balloon, balloonIndex) => { - if (balloon.words > t.maxWordsPerBalloon) { - violations.push({ - kind: 'balloon-words', - pageNumber, - panelNumber, - balloonIndex, - speaker: balloon.speaker, - count: balloon.words, - threshold: t.maxWordsPerBalloon, - limitLabel: 'words in one balloon', - severity: overflowSeverity(balloon.words, t.maxWordsPerBalloon), - anchorQuote: balloon.line.trim(), - }); - } - }); - - // Caption boxes count as balloons, so an over-stuffed caption trips the same - // per-balloon word ceiling as a speech balloon. - m.captionBoxes.forEach((box, boxIndex) => { - if (box.words > t.maxWordsPerBalloon) { - violations.push({ - kind: 'caption-words', - pageNumber, - panelNumber, - boxIndex, - count: box.words, - threshold: t.maxWordsPerBalloon, - limitLabel: 'words in one caption box', - severity: overflowSeverity(box.words, t.maxWordsPerBalloon), - anchorQuote: box.text, - }); - } - }); - - if (m.totalWords > t.maxWordsPerPanel) { - violations.push({ - kind: 'panel-words', - pageNumber, - panelNumber, - count: m.totalWords, - threshold: t.maxWordsPerPanel, - limitLabel: 'words in one panel', - severity: overflowSeverity(m.totalWords, t.maxWordsPerPanel), - anchorQuote: firstLetteringText(m), - }); - } - - if (m.balloonCount > t.maxBalloonsPerPanel) { - violations.push({ - kind: 'panel-balloons', - pageNumber, - panelNumber, - count: m.balloonCount, - threshold: t.maxBalloonsPerPanel, - limitLabel: 'balloons in one panel', - severity: overflowSeverity(m.balloonCount, t.maxBalloonsPerPanel), - anchorQuote: firstLetteringText(m), - }); - } - }); - - if (pageWords > t.maxWordsPerPage) { - violations.push({ - kind: 'page-words', - pageNumber, - count: pageWords, - threshold: t.maxWordsPerPage, - limitLabel: 'words on one page', - severity: overflowSeverity(pageWords, t.maxWordsPerPage), - anchorQuote: '', - }); - } - }); - return violations; -} - -export { LETTERING_SEVERITIES }; +/** + * Comic lettering-density accounting, so the comic-script stage’s inline per-page warnings match the authoritative editorial check. + * + * Re-export of `server/lib/editorial/letteringDensity.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/letteringDensity` import path in the client is unchanged. + */ +export { + DEFAULT_LETTERING_THRESHOLDS, + LETTERING_SEVERITIES, + analyzeComicLettering, + countWords, + overflowSeverity, + panelLetteringMetrics, + sanitizeLetteringThresholds, +} from '../../../server/lib/editorial/letteringDensity.js'; diff --git a/client/src/lib/loraEffect.js b/client/src/lib/loraEffect.js index 42df5602e4..bd12211c23 100644 --- a/client/src/lib/loraEffect.js +++ b/client/src/lib/loraEffect.js @@ -1,26 +1,14 @@ /** - * Client mirror of the LoRA adapter-effect report vocabulary in - * `server/lib/loraEffect.js` (issue #4872). + * How the LoRA manager card renders an adapter-effect measurement (#4872). * - * The server is the decision point — it measures the adapter, decides which - * verdict blocks a render (`loraEffectIssue`: only `zero` does), and caches the - * report in the LoRA sidecar. These helpers exist so the manager card can say - * the same thing the render will, in the same words. `loraEffect.parity.test.js` - * pins the status list against the server module; keep `formatLoraEffect` in - * step with its server twin by hand, or a card and a render log will describe - * one measurement two different ways. - * - * Same shape as `client/src/lib/loraTriggers.js` mirroring its server twin. + * The status vocabulary is re-exported from `server/lib/loraEffect.js` — the + * server measures and records the report, so a new verdict there reaches the + * badge table below without a second edit. The badge tones and + * `loraEffectDetail` are client-only: the server's `formatLoraEffect` always + * returns a sentence for its log line, while the card needs `null` when the + * badge already says everything (or it prints "Unreadable — unreadable"). */ - -// Mirrors LORA_EFFECT_STATUSES in server/lib/loraEffect.js. -export const LORA_EFFECT_STATUSES = Object.freeze({ - OK: 'ok', - ZERO: 'zero', - NONFINITE: 'nonfinite', - UNREADABLE: 'unreadable', - UNMEASURABLE: 'unmeasurable', -}); +export { LORA_EFFECT_STATUSES } from '../../../server/lib/loraEffect.js'; // Badge text + Tailwind tone per status. Presentation is legitimately // client-only, but it lives beside the mirrored status list so a new verdict @@ -41,13 +29,13 @@ export const loraEffectBadge = (status) => LORA_EFFECT_BADGES[status] || { label: status || 'Unknown', tone: 'text-gray-400' }; /** - * One-line summary of a measurement — the mirror of `formatLoraEffect` in - * server/lib/loraEffect.js, and deliberately the same wording. + * One-line detail line for a measurement, worded like the server’s + * `formatLoraEffect` log line but returning `null` where that returns a status. * * Returns `null` when there is nothing to add beyond the badge, so a caller can * omit the separator rather than printing "Unreadable — Unreadable". */ -export const formatLoraEffect = (report) => { +export const loraEffectDetail = (report) => { if (!report) return null; // Both statistics, not just `measured`: the server nulls a non-finite value // while leaving `measured` intact, so a measured report can still arrive with diff --git a/client/src/lib/loraEffect.test.js b/client/src/lib/loraEffect.test.js new file mode 100644 index 0000000000..e65e3b562d --- /dev/null +++ b/client/src/lib/loraEffect.test.js @@ -0,0 +1,91 @@ +/** + * The LoRA manager card's reading of an adapter-effect measurement (#4872). + * + * The status vocabulary is re-exported from `server/lib/loraEffect.js`, so what + * still needs pinning is the client-only layer over it: that every verdict has a + * badge (a new status must never render as a bare slug), that only the verdict + * which actually refuses a render is styled as an error, and that the card's + * detail line agrees with the server's log line wherever there are numbers to + * print — while returning `null`, not the badge word, where there aren't. + */ +import { describe, it, expect } from 'vitest'; +import { + LORA_EFFECT_STATUSES, + LORA_EFFECT_BADGES, + loraEffectBadge, + loraEffectDetail, +} from './loraEffect.js'; +import { + formatLoraEffect as serverFormat, + loraEffectIssue, + normalizeLoraEffectReport, +} from '../../../server/lib/loraEffect.js'; + +// Every report shape the card must handle: a plain measurement, one with each +// skip counter, a partially-zero adapter, and the "no numbers" cases (never +// measured, statistics nulled as non-finite, no reason at all). +const REPORTS = [ + { status: 'ok', measured: 10, medianRms: 0.0031, maxRms: 0.0184, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 0, reason: null }, + { status: 'ok', measured: 8, medianRms: 1e-9, maxRms: 2.5e-8, skippedNonFinite: 2, skippedUnsupported: 0, zeroModules: 0, reason: null }, + { status: 'ok', measured: 8, medianRms: 0.004, maxRms: 0.02, skippedNonFinite: 0, skippedUnsupported: 5, zeroModules: 0, reason: null }, + { status: 'ok', measured: 4, medianRms: 0.004, maxRms: 0.02, skippedNonFinite: 1, skippedUnsupported: 2, zeroModules: 3, reason: null }, + { status: 'zero', measured: 6, medianRms: 0, maxRms: 0, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 6, reason: 'all 6 measurable LoRA module(s) have exactly zero effect' }, + { status: 'unreadable', measured: 0, medianRms: null, maxRms: null, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 0, reason: 'contains no lora_A/lora_B pairs' }, + { status: 'nonfinite', measured: 0, medianRms: null, maxRms: null, skippedNonFinite: 12, skippedUnsupported: 0, zeroModules: 0, reason: 'every module measured NaN' }, + { status: 'unmeasurable', measured: 0, medianRms: null, maxRms: null, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 0, reason: null }, + { status: 'ok', measured: 3, medianRms: null, maxRms: 0.2, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 0, reason: null }, +]; + +const hasNumbers = (report) => report.measured > 0 && report.medianRms !== null && report.maxRms !== null; + +describe('loraEffectBadge', () => { + it('gives every status a badge, so a new verdict can never render as a bare slug', () => { + expect(Object.keys(LORA_EFFECT_BADGES).sort()).toEqual(Object.values(LORA_EFFECT_STATUSES).sort()); + for (const status of Object.values(LORA_EFFECT_STATUSES)) { + expect(loraEffectBadge(status).label).toBeTruthy(); + expect(loraEffectBadge(status).tone).toBeTruthy(); + } + }); + + it('styles exactly the refusing verdict as an error', () => { + // The card must not invent a second blocking-looking status: whichever + // statuses `loraEffectIssue` refuses on are the ones allowed error styling. + const refused = Object.values(LORA_EFFECT_STATUSES) + .filter((status) => loraEffectIssue({ status, reason: 'x' }) !== null); + const errorStyled = Object.entries(LORA_EFFECT_BADGES) + .filter(([, badge]) => badge.tone.includes('port-error')) + .map(([status]) => status); + expect(errorStyled).toEqual(refused); + expect(refused).toEqual([LORA_EFFECT_STATUSES.ZERO]); + }); +}); + +describe('loraEffectDetail', () => { + it('prints a measured report in the server’s own words', () => { + const measured = REPORTS.map(normalizeLoraEffectReport).filter(hasNumbers); + expect(measured.length).toBeGreaterThan(0); + for (const report of measured) { + expect(loraEffectDetail(report)).toBe(serverFormat(report)); + expect(loraEffectDetail(report)).toContain('median RMS'); + } + }); + + it('drops to the reason (never the badge word) where the server prints its status', () => { + // The server's no-statistics fallback is `status[: reason]`, but the card + // already renders the status as a badge beside this text — echoing it would + // read "Unreadable — Unreadable". So the card contributes the reason, or + // nothing at all, and omits the separator. + const unmeasured = REPORTS.map(normalizeLoraEffectReport).filter((r) => !hasNumbers(r)); + expect(unmeasured.length).toBeGreaterThan(0); + for (const report of unmeasured) { + expect(serverFormat(report).startsWith(report.status)).toBe(true); + expect(loraEffectDetail(report)).toBe(report.reason); + expect(loraEffectDetail(report)).not.toBe(loraEffectBadge(report.status).label); + } + }); + + it('has nothing to say about a null report, where the server logs "not measured"', () => { + expect(loraEffectDetail(null)).toBeNull(); + expect(serverFormat(null)).toBe('not measured'); + }); +}); diff --git a/client/src/lib/loraTriggers.js b/client/src/lib/loraTriggers.js index d04940a524..2462983db8 100644 --- a/client/src/lib/loraTriggers.js +++ b/client/src/lib/loraTriggers.js @@ -1,58 +1,16 @@ /** - * Client mirror of the trigger-word predicates in `server/lib/loraTriggers.js` - * (issue #4665). + * The client-side `+ trigger` append, over the trigger-word predicates in + * `server/lib/loraTriggers.js` (#4665). * - * The server is the enforcement point — it weaves each selected LoRA's first - * activation token into the prompt at render time. These two helpers exist so - * the UI can say the same thing the server will do: which token is missing, and - * whether a word is already doing its job in the prompt. Keep the matching rules - * identical to the server module or the picker's hint will contradict the render. + * `firstTriggerWord` / `promptHasTriggerWord` / `separatorFor` are re-exported + * from the server leaf rather than copied: the server is the enforcement point + * (it weaves each selected LoRA's first activation token into the prompt at + * render time), so the picker's hint reads the identical matching rules and + * cannot contradict the render. */ +import { promptHasTriggerWord, separatorFor } from '../../../server/lib/loraTriggers.js'; -import { escapeRegExp } from './textUtils.js'; - -// Only the FIRST trigger word of a LoRA activates it, per the server weave — -// Civitai `trainedWords` routinely lists a dozen loosely-related tags. -export const firstTriggerWord = (words) => { - if (!Array.isArray(words)) return null; - const first = words.find((w) => typeof w === 'string' && w.trim()); - return first ? first.trim() : null; -}; - -// What counts as "inside a word" for the boundary assertions below. Unicode -// letters/digits, not just ASCII, so a non-ASCII trigger or an accented prompt -// gets the same treatment — `\b` and a bare `[A-Za-z0-9_]` class would both -// read `aria` as present inside `ariaé` and silently skip the activation token. -const WORD_CLASS = '\\p{L}\\p{N}_'; -const WORD_CHAR = new RegExp(`[${WORD_CLASS}]`, 'u'); - -// Whole-token, case-insensitive presence test, applied anywhere in the prompt -// (Civitai triggers are commonly woven mid-sentence). Boundaries are asserted -// only where the trigger's own edge is a word character, so `aria_tok` does not -// match inside `aria_token` while a punctuation-edged trigger still matches. -export const promptHasTriggerWord = (prompt, word) => { - const text = typeof prompt === 'string' ? prompt : ''; - const token = typeof word === 'string' ? word.trim() : ''; - if (!text || !token) return false; - const lead = WORD_CHAR.test(token[0]) ? `(? { - if (!trimmed) return ''; - if (/\n/.test(trimmed)) return '\n\n'; - return trimmed.endsWith(',') ? ' ' : ', '; -}; +export { firstTriggerWord, promptHasTriggerWord, separatorFor } from '../../../server/lib/loraTriggers.js'; /** * The "+ trigger" button's append: add a LoRA's trigger words to the prompt, diff --git a/client/src/lib/musicDuration.js b/client/src/lib/musicDuration.js index c11598cc39..051031d98e 100644 --- a/client/src/lib/musicDuration.js +++ b/client/src/lib/musicDuration.js @@ -1,87 +1,14 @@ -// Client-side mirror of `server/lib/musicDuration.js`. MiniMax Music 3 treats -// audio_duration as a ceiling, so the recommendation deliberately includes -// phrase/section space and an ending cushion instead of equating word count to -// the final audio length. - -import { countWords } from '../utils/formatters.js'; - -export const MINIMAX_AUTO_MIN_DURATION_SEC = 60; -export const MINIMAX_AUTO_MAX_DURATION_SEC = 300; -export const MINIMAX_AUTO_DURATION_STEP_SEC = 10; - -const SECONDS_PER_WORD = 0.5; -const SECONDS_PER_LINE_BREAK = 0.25; -const SECONDS_PER_SECTION_BREAK = 3; -const ENDING_CUSHION_SEC = 20; -const SAFETY_MULTIPLIER = 1.2; - -function boundedNumber(value, fallback) { - return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback; -} - -function roundUp(value, step) { - return Math.ceil(value / step) * step; -} - /** - * Analyze structured song lyrics without changing the user's text. + * Lyric-length → song-duration estimation. * - * Tags are recognized when they start a line (`[verse]`, `[outro]`, etc.). A - * tag with text after it still contributes that text to the word count. The - * returned `estimatedDurationSec` is intentionally allowed to exceed the - * engine maximum so the UI can explain when the model's hard ceiling may still - * be too short. + * Re-export of `server/lib/musicDuration.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/musicDuration` import path in the client is unchanged. */ -export function analyzeMusicLyrics(lyrics, options = {}) { - const minDurationSec = boundedNumber(options.minDurationSec, MINIMAX_AUTO_MIN_DURATION_SEC); - const maxDurationSec = Math.max(minDurationSec, boundedNumber(options.maxDurationSec, MINIMAX_AUTO_MAX_DURATION_SEC)); - const lines = typeof lyrics === 'string' ? lyrics.split(/\r?\n/) : []; - const contentLines = []; - let sectionCount = 0; - let hasOutro = false; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line) continue; - const tagged = line.match(/^\[([^\]\r\n]+)\]\s*(.*)$/); - if (tagged) { - sectionCount += 1; - if (/^outro\b/i.test(tagged[1].trim())) hasOutro = true; - if (tagged[2].trim()) contentLines.push(tagged[2].trim()); - continue; - } - contentLines.push(line); - } - - const wordCount = countWords(contentLines.join('\n')); - const contentLineCount = contentLines.length; - const effectiveSectionCount = Math.max(1, sectionCount); - const hasLyrics = wordCount > 0; - const estimatedDurationSec = hasLyrics - ? Math.ceil(( - wordCount * SECONDS_PER_WORD - + Math.max(0, contentLineCount - 1) * SECONDS_PER_LINE_BREAK - + Math.max(0, effectiveSectionCount - 1) * SECONDS_PER_SECTION_BREAK - + ENDING_CUSHION_SEC - ) * SAFETY_MULTIPLIER) - : minDurationSec; - const suggestedDurationSec = Math.max( - minDurationSec, - Math.min(maxDurationSec, roundUp(estimatedDurationSec, MINIMAX_AUTO_DURATION_STEP_SEC)), - ); - - return { - hasLyrics, - wordCount, - contentLineCount, - sectionCount, - hasOutro, - estimatedDurationSec, - suggestedDurationSec, - isCapped: hasLyrics && estimatedDurationSec > maxDurationSec, - }; -} - -export function recommendMinimaxDurationSec(lyrics, options = {}) { - return analyzeMusicLyrics(lyrics, options).suggestedDurationSec; -} +export { + MINIMAX_AUTO_DURATION_STEP_SEC, + MINIMAX_AUTO_MAX_DURATION_SEC, + MINIMAX_AUTO_MIN_DURATION_SEC, + analyzeMusicLyrics, + recommendMinimaxDurationSec, +} from '../../../server/lib/musicDuration.js'; diff --git a/client/src/lib/personaTraitBlend.js b/client/src/lib/personaTraitBlend.js index 9420e2dc70..fa2b24145b 100644 --- a/client/src/lib/personaTraitBlend.js +++ b/client/src/lib/personaTraitBlend.js @@ -1,229 +1,19 @@ /** - * Persona trait-blending (Digital Twin M34 P7). + * Digital-twin persona trait blending, so the Personas UI previews the same directional wording the twin will use. * - * A persona is a named context (Professional, Casual, Family…). Beyond its - * free-text `instructions`, a persona may carry structured `traitAdjustments` - * that modulate the *base* twin's quantitative profile for that context — - * relative nudges to the communication profile (formality / verbosity) plus - * absolute overrides (emoji usage, tone) and directional Big-Five leans. - * - * This module blends those adjustments against the base twin's `traits` and - * renders a "Communication Calibration" directive that prepends to the persona - * preamble (see `digital-twin-context.js`), so the embodied twin shifts voice - * per context without forking the underlying identity documents. - * - * Pure ESM, no Node-only deps — mirrored byte-for-byte to - * `client/src/lib/personaTraitBlend.js` so the Personas UI can preview the same - * directional wording. The server copy is authoritative; the matching server - * test file (`personaTraitBlend.test.js`) is the contract. - */ - -import { clamp } from '../utils/formatters.js'; - -// communicationProfile.formality / .verbosity live on a 1..10 scale; a persona -// nudges them with a relative integer delta in this range. -export const COMM_DELTA_MIN = -9; -export const COMM_DELTA_MAX = 9; - -// Big-Five (OCEAN) base traits live on a 0..1 scale; a persona leans them with -// a relative delta in this range. -export const BIG_FIVE_DELTA_MIN = -1; -export const BIG_FIVE_DELTA_MAX = 1; - -// Emoji-usage vocabulary, shared by the base twin's communication profile, a -// persona's trait-adjustment override (server Zod), and the Personas UI -// dropdown. Living in this byte-parity-guarded mirror means the parity test -// catches any client↔server drift in the enum. -export const EMOJI_USAGE_VALUES = ['never', 'rare', 'occasional', 'frequent']; - -export const BIG_FIVE_LEAN = { - O: { more: 'more open and curious', less: 'more conventional and focused' }, - C: { more: 'more conscientious and organized', less: 'more relaxed and spontaneous' }, - E: { more: 'more outgoing and expressive', less: 'more reserved and measured' }, - A: { more: 'warmer and more accommodating', less: 'more direct and challenging' }, - N: { more: 'more emotionally expressive', less: 'more even-keeled and calm' } -}; - -// Bin a delta's magnitude into slightly / notably / much against the [notably, -// much] thresholds for its scale. One ladder, two scales (1..10 comm deltas use -// [3, 5]; 0..1 Big-Five deltas use [0.2, 0.4]). -function magnitudeAdverb(delta, notably, much) { - const mag = Math.abs(delta); - if (mag >= much) return 'much'; - if (mag >= notably) return 'notably'; - return 'slightly'; -} - -// Bare adverb for a 1..10-scale delta — used where the paired verb already -// encodes direction (e.g. "more concise"). -const commAdverb = (delta) => magnitudeAdverb(delta, 3, 5); - -// Adverb for a 0..1-scale Big-Five delta. -const bigFiveMagnitude = (delta) => magnitudeAdverb(delta, 0.2, 0.4); - -// Signed wording for a fixed-quality 1..10 trait (e.g. formality): the quality -// word stays put and direction is "more"/"less" of it — "much more formal". -function commMagnitude(delta) { - return `${commAdverb(delta)} ${delta > 0 ? 'more' : 'less'}`; -} - -// Verbosity flips its quality word with sign — negative = more concise, -// positive = more elaborate — so it always reads "{adverb} more {quality}". -function verbosityPhrase(delta) { - return `${commAdverb(delta)} more ${delta > 0 ? 'elaborate' : 'concise'}`; -} - -/** - * Does this persona carry any structured trait adjustment worth rendering? - * Empty objects / all-absent fields count as "none". - */ -export function hasTraitAdjustments(adjustments) { - if (!adjustments || typeof adjustments !== 'object') return false; - const { formality, verbosity, emojiUsage, tone, bigFive } = adjustments; - if (typeof formality === 'number' && formality !== 0) return true; - if (typeof verbosity === 'number' && verbosity !== 0) return true; - if (typeof emojiUsage === 'string' && emojiUsage) return true; - if (typeof tone === 'string' && tone.trim()) return true; - if (bigFive && typeof bigFive === 'object') { - return Object.keys(BIG_FIVE_LEAN).some(k => typeof bigFive[k] === 'number' && bigFive[k] !== 0); - } - return false; -} - -/** - * Blend a persona's adjustments against the base communication profile, - * returning the effective values plus the base for "X → Y" rendering. Missing - * base values surface as `null` so callers can render a directional-only line. - */ -export function blendCommunicationProfile(baseProfile, adjustments) { - const base = baseProfile && typeof baseProfile === 'object' ? baseProfile : {}; - const adj = adjustments && typeof adjustments === 'object' ? adjustments : {}; - - const blendScale = (baseVal, delta) => { - const hasBase = typeof baseVal === 'number'; - const hasDelta = typeof delta === 'number' && delta !== 0; - if (!hasDelta) return { base: hasBase ? baseVal : null, effective: hasBase ? baseVal : null, delta: 0 }; - return { - base: hasBase ? baseVal : null, - effective: hasBase ? clamp(baseVal + delta, 1, 10) : null, - delta - }; - }; - - return { - formality: blendScale(base.formality, adj.formality), - verbosity: blendScale(base.verbosity, adj.verbosity), - emojiUsage: typeof adj.emojiUsage === 'string' && adj.emojiUsage - ? { base: base.emojiUsage ?? null, effective: adj.emojiUsage } - : null, - tone: typeof adj.tone === 'string' && adj.tone.trim() - ? { base: base.preferredTone ?? null, effective: adj.tone.trim() } - : null - }; -} - -/** - * Human-readable, base-agnostic descriptions of each adjustment — used by the - * Personas UI preview where the base profile isn't loaded. One short phrase per - * active adjustment; empty array when there's nothing to say. - */ -export function describeTraitAdjustments(adjustments) { - if (!hasTraitAdjustments(adjustments)) return []; - const adj = adjustments; - const lines = []; - - if (typeof adj.formality === 'number' && adj.formality !== 0) { - lines.push(`${commMagnitude(adj.formality)} formal`); - } - if (typeof adj.verbosity === 'number' && adj.verbosity !== 0) { - lines.push(verbosityPhrase(adj.verbosity)); - } - if (typeof adj.emojiUsage === 'string' && adj.emojiUsage) { - lines.push(`emoji usage: ${adj.emojiUsage}`); - } - if (typeof adj.tone === 'string' && adj.tone.trim()) { - lines.push(`tone: ${adj.tone.trim()}`); - } - if (adj.bigFive && typeof adj.bigFive === 'object') { - for (const k of Object.keys(BIG_FIVE_LEAN)) { - const d = adj.bigFive[k]; - if (typeof d === 'number' && d !== 0) { - const lean = d > 0 ? BIG_FIVE_LEAN[k].more : BIG_FIVE_LEAN[k].less; - lines.push(`${bigFiveMagnitude(d)} ${lean}`); - } - } - } - return lines; -} - -/** - * Render the "Communication Calibration" directive block that prepends to a - * persona's preamble. Blends `adjustments` against the base twin's `traits` - * (communicationProfile + bigFive). Returns '' when the persona has no - * adjustments so the preamble stays unchanged for instructions-only personas. + * Re-export of `server/lib/personaTraitBlend.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/personaTraitBlend` import path in the client is unchanged. */ -export function renderTraitBlendDirective(baseTraits, adjustments, personaName = '') { - if (!hasTraitAdjustments(adjustments)) return ''; - - const traits = baseTraits && typeof baseTraits === 'object' ? baseTraits : {}; - const blended = blendCommunicationProfile(traits.communicationProfile, adjustments); - const lines = []; - - // `phrase(delta)` returns the full directional description for this scale's - // delta (e.g. "notably more formal" / "much more concise"). - const renderScale = (label, slot, phrase) => { - if (!slot || slot.delta === 0) return; - const text = phrase(slot.delta); - if (slot.base !== null && slot.effective !== null) { - lines.push(`- ${label}: ${slot.base} → ${slot.effective} (${text})`); - } else { - // No baseline recorded — render the directional intent relative to default. - lines.push(`- ${label}: ${text} than your natural default`); - } - }; - - renderScale('Formality', blended.formality, (d) => `${commMagnitude(d)} formal`); - renderScale('Verbosity', blended.verbosity, verbosityPhrase); - - if (blended.emojiUsage) { - const from = blended.emojiUsage.base ? ` (baseline ${blended.emojiUsage.base})` : ''; - lines.push(`- Emoji usage: ${blended.emojiUsage.effective}${from}`); - } - if (blended.tone) { - const from = blended.tone.base ? ` (baseline ${blended.tone.base})` : ''; - lines.push(`- Tone: ${blended.tone.effective}${from}`); - } - - // Big-Five leans, rendered as directional personality nudges. - const adjBigFive = adjustments.bigFive && typeof adjustments.bigFive === 'object' ? adjustments.bigFive : {}; - const baseBigFive = traits.bigFive && typeof traits.bigFive === 'object' ? traits.bigFive : {}; - const leanLines = []; - for (const k of Object.keys(BIG_FIVE_LEAN)) { - const d = adjBigFive[k]; - if (typeof d !== 'number' || d === 0) continue; - const lean = d > 0 ? BIG_FIVE_LEAN[k].more : BIG_FIVE_LEAN[k].less; - const baseVal = typeof baseBigFive[k] === 'number' ? baseBigFive[k] : null; - if (baseVal !== null) { - const eff = clamp(baseVal + d, 0, 1); - leanLines.push(`${bigFiveMagnitude(d)} ${lean} (${baseVal.toFixed(2)} → ${eff.toFixed(2)})`); - } else { - leanLines.push(`${bigFiveMagnitude(d)} ${lean}`); - } - } - if (leanLines.length > 0) { - lines.push(`- Personality lean: ${leanLines.join('; ')}`); - } - - if (lines.length === 0) return ''; - - const heading = personaName - ? `## Communication Calibration (${personaName} context)` - : '## Communication Calibration'; - - return [ - heading, - 'Modulate your baseline communication for this context:', - ...lines, - 'Where a baseline value is unknown, apply the directional adjustment relative to your natural default.' - ].join('\n'); -} +export { + BIG_FIVE_DELTA_MAX, + BIG_FIVE_DELTA_MIN, + BIG_FIVE_LEAN, + COMM_DELTA_MAX, + COMM_DELTA_MIN, + EMOJI_USAGE_VALUES, + blendCommunicationProfile, + describeTraitAdjustments, + hasTraitAdjustments, + renderTraitBlendDirective, +} from '../../../server/lib/personaTraitBlend.js'; diff --git a/client/src/lib/ports.js b/client/src/lib/ports.js index 4f63d9bde1..f6d95aa4f9 100644 --- a/client/src/lib/ports.js +++ b/client/src/lib/ports.js @@ -1,21 +1,11 @@ -// Client mirror of the small subset of `PORTS` the UI needs. -// -// `ecosystem.config.cjs` (top-level `PORTS` object) is the SOURCE OF TRUTH — see -// docs/PORTS.md. `server/lib/ports.js` is the server-side mirror. This file -// exists because the browser bundle can't import either one: the ecosystem -// config is CommonJS living outside the client Vite root, and the server mirror -// is server ESM. `ports.parity.test.js` fails if these drift from the config. -// -// Decision: mirror rather than fetch from an endpoint — these values are needed -// synchronously at module scope for form defaults and static help text, before -// any API round-trip could resolve. -export const PORTS = Object.freeze({ - FLEET_LLM: 18022, // Queued, authenticated model host API - API: 5555, // Express API server (HTTPS when a Tailscale cert is active) - API_LOCAL: 5553, // Loopback-only HTTP mirror of API — binds only when HTTPS is on - UI: 5554, // Vite dev server (client) -}); - -// The port a newly-added federation peer is assumed to serve its API on. -// Mirrors `DEFAULT_PEER_PORT` in server/lib/ports.js. -export const DEFAULT_PEER_PORT = PORTS.API; +/** + * Re-export of the `PORTS` map and `DEFAULT_PEER_PORT` from the pure server leaf + * `server/lib/ports.js`. + * + * `ecosystem.config.cjs` (top-level `PORTS`) remains the SOURCE OF TRUTH — see + * docs/PORTS.md; `server/lib/ports.test.js` fails if the server map drifts from + * it. Importing rather than copying means the UI cannot drift from the server on + * top of that. Use these instead of re-hardcoding a port literal in a form + * default, a copy-paste help string, or a cross-machine URL. + */ +export { PORTS, DEFAULT_PEER_PORT } from '../../../server/lib/ports.js'; diff --git a/client/src/lib/ports.parity.test.js b/client/src/lib/ports.parity.test.js deleted file mode 100644 index eb2536493c..0000000000 --- a/client/src/lib/ports.parity.test.js +++ /dev/null @@ -1,32 +0,0 @@ -// @vitest-environment node -import { describe, it, expect } from 'vitest'; -import { createRequire } from 'node:module'; -import { PORTS, DEFAULT_PEER_PORT } from './ports.js'; -import { PORTS as SERVER_PORTS } from '../../../server/lib/ports.js'; - -const require = createRequire(import.meta.url); -// ecosystem.config.cjs is the source of truth for every PortOS port. -const ecosystem = require('../../../ecosystem.config.cjs'); - -describe('client/src/lib/ports.js mirror parity', () => { - it('matches ecosystem.config.cjs for every mirrored port', () => { - for (const key of Object.keys(PORTS)) { - expect(ecosystem.PORTS[key], `ecosystem.config.cjs is missing PORTS.${key}`).toBeDefined(); - expect(PORTS[key], `client mirror PORTS.${key} drifted`).toBe(ecosystem.PORTS[key]); - } - }); - - it('matches the server mirror in server/lib/ports.js for every mirrored port', () => { - for (const key of Object.keys(PORTS)) { - expect(PORTS[key], `server mirror PORTS.${key} drifted`).toBe(SERVER_PORTS[key]); - } - }); - - it('mirrors only the UI-facing subset', () => { - expect(Object.keys(PORTS).sort()).toEqual(['API', 'API_LOCAL', 'FLEET_LLM', 'UI']); - }); - - it('defaults a new peer to the API port', () => { - expect(DEFAULT_PEER_PORT).toBe(ecosystem.PORTS.API); - }); -}); diff --git a/client/src/lib/postRotation.js b/client/src/lib/postRotation.js index bdf2992455..c158b7bb35 100644 --- a/client/src/lib/postRotation.js +++ b/client/src/lib/postRotation.js @@ -1,89 +1,8 @@ /** - * Deterministic day-based rotation for POST practice selection (issue #5319). + * Deterministic day-based rotation for POST practice selection. * - * Every POST "what should I practice?" surface used to resolve equivalent - * candidates by input order, so the same drill won the top slot every day — - * Elements from the memory tier, digit-span from the heuristic tiers. These - * helpers replace that fixed order with a rotation keyed by the local day, so - * the choice varies across days while staying repeatable for the same day and - * the same inputs (no randomness — the daily routine must be reproducible). - * - * Pure, dependency-free, and MIRRORED from `server/lib/postRotation.js` so the - * client's Quick-session domain picks and the server's recommendation tiers - * rotate identically. Keep the two files in sync — the server-side - * `postRotation.mirror.test.js` fails when their code diverges. - */ - -/** - * Days since the epoch for a `YYYY-MM-DD` local day label, as a rotation seed. - * Returns null when the label isn't parseable, so callers fall back to plain - * priority order rather than rotating off a garbage seed. - */ -function dayOrdinal(dayKey) { - if (typeof dayKey !== 'string') return null; - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayKey.trim()); - if (!match) return null; - const [, y, m, d] = match; - const at = Date.UTC(Number(y), Number(m) - 1, Number(d)); - return Number.isNaN(at) ? null : Math.floor(at / 86400000); -} - -/** - * Offset into a list of `length` equivalent candidates for the given local day. - * Always in range; 0 for an unparseable day or an empty list. - */ -export function dayRotationIndex(dayKey, length) { - const size = Math.trunc(length) || 0; - if (size <= 1) return 0; - const ordinal = dayOrdinal(dayKey); - if (ordinal === null) return 0; - return ((ordinal % size) + size) % size; -} - -/** - * Order a priority-ranked candidate list so that: - * 1. candidates NOT practiced inside the recency window come first, - * 2. within that, lower `rank` (higher priority) comes first, - * 3. and genuinely equivalent candidates — same recency bucket, same rank — - * rotate by local day instead of resolving to input order. - * - * Nothing is dropped, so a caller that wants the whole tier keeps every entry - * and a caller that wants one pick reads `[0]`. With a single candidate (or no - * day key) the input order is returned unchanged, which is what makes "fall - * back to the only available option" free. - * - * @param {Array} candidates - * @param {object} [options] - * @param {string|null} [options.dayKey] - local `YYYY-MM-DD` - * @param {(candidate: any) => boolean} [options.isRecent] - practiced in the window - * @param {(candidate: any) => number} [options.rank] - lower is higher priority - * @returns {Array} a new, reordered array + * Re-export of `server/lib/postRotation.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/postRotation` import path in the client is unchanged. */ -export function orderByRecencyRotation(candidates, { dayKey = null, isRecent = () => false, rank = () => 0 } = {}) { - const list = (candidates || []).filter(Boolean); - if (list.length <= 1) return list; - - const decorated = list.map((candidate, index) => ({ - candidate, - index, - recent: isRecent(candidate) ? 1 : 0, - rank: Number(rank(candidate)) || 0, - })); - decorated.sort((a, b) => (a.recent - b.recent) || (a.rank - b.rank) || (a.index - b.index)); - - const out = []; - let start = 0; - while (start < decorated.length) { - let end = start; - while ( - end < decorated.length - && decorated[end].recent === decorated[start].recent - && decorated[end].rank === decorated[start].rank - ) end += 1; - const group = decorated.slice(start, end); - const offset = dayRotationIndex(dayKey, group.length); - out.push(...group.slice(offset), ...group.slice(0, offset)); - start = end; - } - return out.map(entry => entry.candidate); -} +export { dayRotationIndex, orderByRecencyRotation } from '../../../server/lib/postRotation.js'; diff --git a/client/src/lib/reactorVideoClip.js b/client/src/lib/reactorVideoClip.js index 293739006d..054e30ae6c 100644 --- a/client/src/lib/reactorVideoClip.js +++ b/client/src/lib/reactorVideoClip.js @@ -1,80 +1,20 @@ -// Client mirror of `server/lib/reactorVideoClip.js` — the reactor.inc fast-h3 -// clip contract the VideoGen form has to respect BEFORE it submits: the 800 -// character prompt cap (fast-h3 rejects a longer prompt outright, so the form -// counts characters rather than letting the render 400) and the clip lengths -// its picker offers. Enforced by the "client mirror" suite in -// `server/lib/reactorVideoClip.test.js`: if this drifts from the server file, -// that test fails. Update the SERVER file first, then this one. - -/** Longest prompt fast-h3 accepts, in characters. */ -export const REACTOR_MAX_PROMPT_LENGTH = 800; - -/** Shortest clip fast-h3 accepts (124 frames at 24fps). */ -export const REACTOR_MIN_CLIP_SECONDS = 5.167; - -/** Longest clip fast-h3 accepts (345 frames at 24fps). */ -export const REACTOR_MAX_CLIP_SECONDS = 14.375; - /** - * Clip lengths the picker offers, ascending — the two exact endpoints plus - * every whole second between them. fast-h3 accepts a continuous value in that - * range, so a free-text seconds box mostly offered a way to type one it - * refuses; whole seconds are frame-aligned at 24fps. + * Reactor clip/canvas/aspect contract the VideoGen form builds from. + * + * Re-export of `server/lib/reactorVideoClip.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/reactorVideoClip` import path in the client is unchanged. */ -export const REACTOR_CLIP_LENGTHS = Object.freeze([ - REACTOR_MIN_CLIP_SECONDS, 6, 7, 8, 9, 10, 11, 12, 13, 14, REACTOR_MAX_CLIP_SECONDS, -]); - -/** Fallback when no clip length is chosen. */ -export const REACTOR_DEFAULT_CLIP_LENGTH = 6; - -/** Human label for one picker entry — the endpoints are odd enough to need naming. */ -export const reactorClipLengthLabel = (seconds) => ( - seconds === REACTOR_MIN_CLIP_SECONDS ? `${seconds} seconds (min)` - : seconds === REACTOR_MAX_CLIP_SECONDS ? `${seconds} seconds (max)` - : `${seconds} seconds` -); - -/** - * The canvases fast-h3 renders. The aspect string is the whole choice — every - * canvas holds a 768px short edge, so the pixel size falls out of it. Reactor - * FITS a starting frame to the session canvas, so picking the canvas that - * matches the image is what keeps a portrait photo from being squeezed into a - * landscape session. Widest-first so the picker reads landscape → portrait. - */ -export const REACTOR_CANVASES = Object.freeze([ - Object.freeze({ aspect: '16:9', width: 1344, height: 768, label: 'Landscape 16:9 · 1344×768' }), - Object.freeze({ aspect: '4:3', width: 1024, height: 768, label: 'Standard 4:3 · 1024×768' }), - Object.freeze({ aspect: '1:1', width: 768, height: 768, label: 'Square 1:1 · 768×768' }), - Object.freeze({ aspect: '9:16', width: 768, height: 1344, label: 'Portrait 9:16 · 768×1344' }), -]); - -/** Just the aspect strings — the closed set `set_canvas` accepts. */ -export const REACTOR_ASPECTS = Object.freeze(REACTOR_CANVASES.map((c) => c.aspect)); - -/** Canvas a text-only render (or an unreadable starting frame) falls back to. */ -export const REACTOR_DEFAULT_ASPECT = '16:9'; - -/** Canvas record for an aspect string; the default canvas for anything else. */ -export const reactorCanvas = (aspect) => ( - REACTOR_CANVASES.find((c) => c.aspect === aspect) - || REACTOR_CANVASES.find((c) => c.aspect === REACTOR_DEFAULT_ASPECT) -); - -/** - * The fast-h3 canvas closest to a starting frame's own shape, compared in log - * space so twice-as-wide and half-as-wide score the same distance. Ties keep - * the earlier (wider) canvas; anything unmeasurable answers the default. - */ -export const nearestReactorAspect = (width, height) => { - const w = Number(width); - const h = Number(height); - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return REACTOR_DEFAULT_ASPECT; - const target = Math.log(w / h); - let best = null; - for (const canvas of REACTOR_CANVASES) { - const distance = Math.abs(Math.log(canvas.width / canvas.height) - target); - if (best === null || distance < best.distance) best = { aspect: canvas.aspect, distance }; - } - return best.aspect; -}; +export { + REACTOR_ASPECTS, + REACTOR_CANVASES, + REACTOR_CLIP_LENGTHS, + REACTOR_DEFAULT_ASPECT, + REACTOR_DEFAULT_CLIP_LENGTH, + REACTOR_MAX_CLIP_SECONDS, + REACTOR_MAX_PROMPT_LENGTH, + REACTOR_MIN_CLIP_SECONDS, + nearestReactorAspect, + reactorCanvas, + reactorClipLengthLabel, +} from '../../../server/lib/reactorVideoClip.js'; diff --git a/client/src/lib/repoUrl.js b/client/src/lib/repoUrl.js index ba62772556..a0485f2db7 100644 --- a/client/src/lib/repoUrl.js +++ b/client/src/lib/repoUrl.js @@ -1,207 +1,16 @@ /** - * Git repository URL parsing — MIRROR of `server/lib/repoUrl.js` - * (authoritative there). + * Repository URL parsing/normalization. * - * The Brain capture boxes preview what the server will do with a bare URL: a - * github.com / gitlab.com repo gets cloned, which unlocks the post-clone agent - * options (malware scan / learn-from-repo). A looser client offers those options - * for a URL the server files as a plain bookmark; a tighter one hides them for a - * repo that will in fact be cloned. - * - * Port any change from the server copy verbatim; parity is enforced by - * `server/lib/repoUrl.mirror.test.js`. - */ - -// The host allowlist, and the two behaviors that actually differ between hosts. -// They live IN the table rather than as `host === 'github.com'` branches further -// down, so adding a host is one entry here and nothing else — and so each flag -// has to be decided deliberately for the new host rather than inherited from -// whichever existing host the branch happened to compare against. -// -// provider stable id stored on a link record (`repoHost` holds the -// hostname itself) -// namespace.maxDepth how many path segments before the project may be the -// namespace. 1 = no subgroups (GitHub: anything past -// owner/repo is a deep link). GitLab nests subgroups, but -// the depth is CAPPED rather than unbounded: a bare GitLab -// URL carries no marker separating `group/sub/project` from -// `group/project/route/...`, so an unbounded walk reads a -// project asset URL as a deep namespace and manufactures -// directories INSIDE an existing clone. The cap also bounds -// the on-disk layout, which `repoCloner`'s staging sweep -// derives its recursion depth from. -// namespace.allowDots whether a namespace segment may contain a dot. GitHub -// logins may not; GitLab group paths may. Keeping it off -// for the flat-clone host is also what makes a hostname -// collision impossible (`github.com/gitlab.com/x` cannot -// parse, so it cannot land on another host's clone root). -// flatClonePath LEGACY CARVE-OUT — clone to `/` with no -// hostname level, so clones made before PortOS supported a -// second host stay exactly where their link record says -// they are. Never set this for a newly added host. -export const REPO_HOSTS = Object.freeze({ - 'github.com': { provider: 'github', namespace: { maxDepth: 1, allowDots: false }, flatClonePath: true }, - 'gitlab.com': { provider: 'gitlab', namespace: { maxDepth: 3, allowDots: true }, flatClonePath: false }, -}); - -/** The deepest `//` layout any host in the table produces. */ -export const MAX_REPO_PATH_DEPTH = Object.values(REPO_HOSTS).reduce( - (deepest, { namespace, flatClonePath }) => Math.max(deepest, (flatClonePath ? 0 : 1) + namespace.maxDepth + 1), - 0, -); - -// The owner/repo pair is a PATH OPERAND, not just a label: the cloner clones -// into `join(reposDir, …owner, repo)` and the resulting `localPath` is later -// handed to an agent as the directory to scan/study. So every segment is -// matched against the character sets the hosts actually allow, NOT "anything -// but a slash" — the loose form parsed `https://github.com/../evil` as owner -// `..`, which resolves OUTSIDE the managed clone root. -// owner: a login (or a GitLab group/subgroup). Both forms REQUIRE a leading -// alphanumeric, which is what makes `.` and `..` unrepresentable; the -// dotted form is gated per host (see `namespace.allowDots`). -// repo: alphanumerics plus `_`, `.`, `-` — a leading dot is legal (`.github` -// is a real repository), so dot segments are rejected explicitly below. -const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/; -const DOTTED_OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; -const REPO_RE = /^[A-Za-z0-9_.-]+$/; - -// `REPO_RE`'s character class admits the dot segments `.` and `..`, which would -// escape (or collapse to) the clone root the same way a bad owner does. The -// owner pattern already rejects them by requiring a leading alphanumeric. -const DOT_SEGMENTS = new Set(['.', '..']); - -// SSH remote: git@host:path (optionally `ssh://git@host/path`). -const SSH_RE = /^(?:ssh:\/\/)?git@([^:/\s]+)[:/](\S+)$/i; - -// Any scheme (or none), optional userinfo: host[:port][/path]. Anchored at the -// host so `https://evil.com/github.com/o/r` is NOT read as a GitHub repo, and -// whitespace-free end-to-end so `github.com/a b/c` is rejected outright. -const HTTP_RE = /^(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/)?(?:[^/@\s]+@)?([^/?#\s]+)(\/\S*)?$/; - -// Path words that begin a host's own UI route rather than another namespace -// segment. GitLab's modern deep links use the `/-/` separator (handled -// separately), but its legacy links — and every GitHub deep link — put the -// route word directly after the project, so the segment walk stops here. -const RESERVED_PATH_SEGMENTS = new Set([ - 'tree', 'blob', 'raw', 'commit', 'commits', 'compare', 'branches', 'tags', - 'releases', 'issues', 'pull', 'pulls', 'merge_requests', 'wiki', 'wikis', - 'actions', 'pipelines', 'settings', 'activity', 'network', 'graphs', 'blame', - // GitLab serves these directly off the project path with no `/-/` marker, so - // without them a badge or upload URL parses as a deeper namespace. - 'badges', 'uploads', 'archive', 'edit', 'forks', 'starrers', 'members', - 'artifacts', 'jobs', 'boards', 'milestones', 'labels', 'snippets', - 'environments', 'analytics', 'insights', 'hooks', 'container_registry', -]); - -/** - * Parse a repository URL into `{ host, provider, owner, repo }`, or null when - * the URL isn't a repository on a supported host (or names a path-unsafe - * owner/repo). - * - * `owner` is a single login on GitHub and may be a `group/subgroup` path on - * GitLab; every one of its segments is validated, so it stays path-safe. - * - * @param {string} url - * @returns {{ host: string, provider: string, owner: string, repo: string } | null} - */ -export function parseRepoUrl(url) { - if (!url) return null; - const normalized = String(url).trim(); - // A backslash defeats the host anchor: WHATWG maps `\` to `/` inside the - // authority, so `https://evil.example.com\@github.com/o/r` resolves to - // evil.example.com in a browser while the regex below reads github.com. The - // clone would still go to the real github.com (repoCloneUrl rebuilds from the - // parsed host), but the link would be STORED and rendered as a trusted repo - // whose href points at the attacker. - if (normalized.includes('\\')) return null; - - const match = normalized.match(SSH_RE) || normalized.match(HTTP_RE); - if (!match) return null; - - const host = match[1].toLowerCase().replace(/^www\./, '').replace(/:\d+$/, ''); - const hostConfig = REPO_HOSTS[host]; - if (!hostConfig) return null; - - // Drop the query/hash, then GitLab's `/-/` deep-link separator. GitHub has a - // fixed owner/repo pair, so identify those two operands before looking at any - // later route words — a repository itself may be named `issues`, `settings`, - // or `tree`, and arbitrary GitHub deep links are still valid repo URLs. - let path = (match[2] || '').split(/[?#]/)[0].replace(/^\//, ''); - const dashIndex = path.indexOf('/-/'); - if (dashIndex !== -1) path = path.slice(0, dashIndex); - - const pathSegments = path.split('/').filter(Boolean); - const { maxDepth, allowDots } = hostConfig.namespace; - let segments = pathSegments; - if (hostConfig.flatClonePath) { - segments = pathSegments.slice(0, maxDepth + 1); - } else { - const routeIndex = pathSegments.findIndex((segment, index) => - index >= 2 && RESERVED_PATH_SEGMENTS.has(segment.toLowerCase())); - if (routeIndex !== -1) segments = pathSegments.slice(0, routeIndex); - } - if (segments.length < 2) return null; - - // Past the cap the path is a deep link into the project, not a deeper - // namespace — reading it as one would clone into (and create directories - // inside) an existing checkout. - if (segments.length > maxDepth + 1) return null; - - const ownerSegments = segments.slice(0, -1); - const repo = segments[segments.length - 1].replace(/\.git$/i, ''); - - const ownerPattern = allowDots ? DOTTED_OWNER_RE : OWNER_RE; - if (!ownerSegments.every(segment => ownerPattern.test(segment))) return null; - if (!repo || DOT_SEGMENTS.has(repo) || !REPO_RE.test(repo)) return null; - - return { - host, - provider: hostConfig.provider, - owner: ownerSegments.join('/'), - repo, - }; -} - -/** - * True when the URL points at a repository on a supported host. - * - * @param {string} url - * @returns {boolean} - */ -export function isRepoUrl(url) { - return parseRepoUrl(url) !== null; -} - -/** - * The browsable web URL for a parsed repo — what to `href` when a record stores - * the scp-style SSH remote a browser can't follow. - * - * @param {{ host: string, owner: string, repo: string }} parsed - * @returns {string} - */ -export function repoBrowseUrl({ host, owner, repo }) { - return `https://${host}/${owner}/${repo}`; -} - -/** - * Parse a URL only when it is a GitHub repository. The GitHub-only callers are - * the ones whose downstream really is GitHub-specific (the Eidoverse worlds - * repo, which is pushed to with a GitHub token), NOT the Brain's repo capture. - * - * @param {string} url - * @returns {{ owner: string, repo: string, isGitHub: true } | null} - */ -export function parseGitHubUrl(url) { - const parsed = parseRepoUrl(url); - return parsed?.provider === 'github' ? { owner: parsed.owner, repo: parsed.repo, isGitHub: true } : null; -} - -/** - * True when the URL points at a GitHub repository specifically. - * - * @param {string} url - * @returns {boolean} + * Re-export of `server/lib/repoUrl.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/repoUrl` import path in the client is unchanged. */ -export function isGitHubRepoUrl(url) { - return parseGitHubUrl(url) !== null; -} +export { + MAX_REPO_PATH_DEPTH, + REPO_HOSTS, + isGitHubRepoUrl, + isRepoUrl, + parseGitHubUrl, + parseRepoUrl, + repoBrowseUrl, +} from '../../../server/lib/repoUrl.js'; diff --git a/client/src/lib/scenePrompt.js b/client/src/lib/scenePrompt.js index d205cc1674..d31c694ad0 100644 --- a/client/src/lib/scenePrompt.js +++ b/client/src/lib/scenePrompt.js @@ -1,178 +1,19 @@ -// Mirror of server/lib/scenePrompt.js — keep byte-for-byte in sync. -// The shape-invariant tests in server/lib/scenePrompt.test.js are the contract. - -import { mapCanonDescriptorFragments, richCanonDescriptorFragments } from './canonPrompt.js'; -import { escapeRegExp } from './textUtils.js'; - -const PROMPT_MAX = 1900; - -export const normalizeSlugline = (s) => String(s || '') - .toUpperCase() - .replace(/[—–-]/g, ' ') - .replace(/[.,:;]/g, '') - .replace(/\s+/g, ' ') - .trim(); - -export const normCharKey = (s) => String(s || '').trim().toLowerCase().replace(/^the\s+/, ''); - -export function buildCharByKey(allCharacters) { - const map = new Map(); - for (const profile of allCharacters || []) { - map.set(normCharKey(profile.name), profile); - for (const alias of profile.aliases || []) map.set(normCharKey(alias), profile); - } - return map; -} - -export function matchSceneCharacters(sceneCharacterNames = [], charByKey) { - if (!Array.isArray(sceneCharacterNames) || !sceneCharacterNames.length) return []; - const matched = []; - const seen = new Set(); - for (const name of sceneCharacterNames) { - const profile = charByKey?.get(normCharKey(name)); - if (profile && !seen.has(profile.id || profile.name)) { - matched.push(profile); - seen.add(profile.id || profile.name); - } - } - return matched; -} - -export function matchCharactersInText(text, allCharacters) { - return matchEntriesByCandidates(text, allCharacters, (c) => [c.name, ...(c.aliases || [])]); -} - -export function buildPlaceByKey(allSettings) { - const map = new Map(); - for (const setting of allSettings || []) { - const key = normalizeSlugline(setting.slugline || setting.name); - if (!key) continue; - map.set(key, setting); - } - return map; -} - -export function matchScenePlace(sceneSlugline, placeByKey) { - if (!sceneSlugline) return null; - return placeByKey?.get(normalizeSlugline(sceneSlugline)) || null; -} - -function matchEntriesByCandidates(text, entries, candidatesFn) { - if (!text || !Array.isArray(entries) || !entries.length) return []; - const haystack = String(text); - const matched = []; - const seen = new Set(); - const wordBoundary = (needle) => { - if (!needle) return false; - const escaped = escapeRegExp(needle); - // Unicode-aware boundary instead of ASCII `\b`: a name starting/ending with a - // non-ASCII letter (José, Élodie, Zoë) has no `\b` adjacent to the accented - // char, so `\b…\b` would silently miss it. Lookarounds over `[\p{L}\p{N}_]` - // with the `u` flag reproduce word-boundary semantics for all scripts - // (still won't match "Mira" inside "Miranda"). - return new RegExp(`(? [p.name]); -} - -export function matchObjectsInText(text, allObjects) { - return matchEntriesByCandidates(text, allObjects, (o) => [o.name, ...(o.aliases || [])]); -} - -// Append a user-selected wardrobe description AFTER physicalDescription -// without clobbering it — mirror of server/lib/scenePrompt.js#appendWardrobe. -function appendWardrobe(base, wardrobeDesc) { - if (!wardrobeDesc) return base; - const wearing = `Wearing: ${wardrobeDesc}`; - if (!base) return wearing; - const sep = /[.!?]$/.test(base) ? ' ' : '. '; - return `${base}${sep}${wearing}`; -} - -export function buildScenePrompt(workTitle, scene, matchedCharacters, worldStyle = '', matchedPlace = null) { - const stylePart = worldStyle && worldStyle.trim() ? `${worldStyle.trim()}. ` : ''; - const titlePart = workTitle ? `${workTitle}. ` : ''; - const visual = scene?.visualPrompt || scene?.description || ''; - - const intExtPart = matchedPlace?.intExt === 'INT' - ? 'Interior' - : matchedPlace?.intExt === 'EXT' - ? 'Exterior' - : ''; - const todPart = typeof matchedPlace?.timeOfDay === 'string' && matchedPlace.timeOfDay - ? matchedPlace.timeOfDay - : ''; - const placeMetaFrag = [intExtPart, todPart].filter(Boolean).join(', '); - const baselineFrags = matchedPlace - ? mapCanonDescriptorFragments(richCanonDescriptorFragments('place', matchedPlace), { trailingPeriod: true }) - : []; - const placeFrags = matchedPlace ? [ - placeMetaFrag ? `${placeMetaFrag}.` : '', - ...baselineFrags, - ].filter(Boolean) : []; - - // Per-scene wardrobe picks: `scene.characterAppearances` is - // [{ characterId, wardrobeId? }]. Mirror of server/lib/scenePrompt.js. - const appearanceByCharId = new Map( - (Array.isArray(scene?.characterAppearances) ? scene.characterAppearances : []) - .filter((a) => a && a.characterId) - .map((a) => [a.characterId, a]), - ); - - // Accept either `physicalDescription` (writers-room shape) or - // `description` (pipeline shape) — the composer doesn't care which - // field carries the visual descriptor. - const featuringFragments = (matchedCharacters || []) - .map((c) => { - const base = (c.physicalDescription || c.description || '').trim(); - const appearance = appearanceByCharId.get(c.id); - const wardrobe = appearance?.wardrobeId - ? (c.wardrobes || []).find((w) => w && w.id === appearance.wardrobeId) - : null; - return { name: c.name, desc: appendWardrobe(base, (wardrobe?.description || '').trim()) }; - }) - .filter((c) => c.desc) - .map((c) => `${c.name}: ${c.desc}`); - - const PREFIX = 'Featuring — '; - const reserveCore = stylePart.length + titlePart.length + visual.length + 4; - let budget = PROMPT_MAX - reserveCore; - - const placeFit = []; - for (const frag of placeFrags) { - const cost = (placeFit.length === 0 ? 0 : 1) + frag.length; - if (cost > budget) break; - placeFit.push(frag); - budget -= cost; - } - - budget -= PREFIX.length; - const charFit = []; - for (const frag of featuringFragments) { - const cost = (charFit.length === 0 ? 0 : 1) + frag.length; - if (cost > budget) break; - charFit.push(frag); - budget -= cost; - } - - const segs = []; - if (stylePart) segs.push(stylePart.trim()); - if (titlePart) segs.push(titlePart.trim()); - if (placeFit.length > 0) segs.push(placeFit.join(' ')); - if (charFit.length > 0) segs.push(`${PREFIX}${charFit.join(' ')}`); - if (visual) segs.push(visual); - return segs.filter(Boolean).join(' ').slice(0, PROMPT_MAX); -} +/** + * Scene-prompt composer and the bible matchers it uses. + * + * Re-export of `server/lib/scenePrompt.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/scenePrompt` import path in the client is unchanged. + */ +export { + buildCharByKey, + buildPlaceByKey, + buildScenePrompt, + matchCharactersInText, + matchObjectsInText, + matchPlacesInText, + matchSceneCharacters, + matchScenePlace, + normCharKey, + normalizeSlugline, +} from '../../../server/lib/scenePrompt.js'; diff --git a/client/src/lib/seasonStructure.js b/client/src/lib/seasonStructure.js index 34f6568dfe..f15ed57336 100644 --- a/client/src/lib/seasonStructure.js +++ b/client/src/lib/seasonStructure.js @@ -1,44 +1,8 @@ /** - * Mirror of server/lib/seasonStructure.js — kept here so the client-side - * issue-count hint can render without round-tripping through the API. + * Season/episode structure vocabulary. * - * 6–10 issues per volume/season is the comic-as-TV sweet spot. Single volume - * up to 12; 3-volume arc lands roughly at 18–30. The "3 × 8 = 24" point is - * the canonical "3-season arc" target. - * - * Keep this in sync with the server file — there's no shared bundle, but - * the function bodies are tiny and tested on the server side. + * Re-export of `server/lib/seasonStructure.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/seasonStructure` import path in the client is unchanged. */ - -function pickSeasonCount(total) { - if (total <= 12) return 1; - if (total <= 17) return 2; - if (total <= 32) return 3; - if (total <= 44) return 4; - return 5; -} - -export function recommendStructure(total) { - const n = Math.floor(Number(total) || 0); - if (n <= 0) return null; - const seasons = pickSeasonCount(n); - const base = Math.floor(n / seasons); - const remainder = n % seasons; - const perSeason = Array.from( - { length: seasons }, - (_, i) => base + (i < remainder ? 1 : 0), - ); - return { seasons, perSeason }; -} - -export function describeStructure(structure) { - if (!structure) return ''; - const { seasons, perSeason } = structure; - const allSame = perSeason.every((n) => n === perSeason[0]); - if (allSame) { - return seasons === 1 - ? `1 volume × ${perSeason[0]} episodes` - : `${seasons} volumes × ${perSeason[0]} episodes`; - } - return `${seasons} volumes × ~${Math.round(perSeason.reduce((a, b) => a + b, 0) / seasons)} (${perSeason.join(', ')})`; -} +export { describeStructure, recommendStructure } from '../../../server/lib/seasonStructure.js'; diff --git a/client/src/lib/shotContinuity.js b/client/src/lib/shotContinuity.js index 4fb30b6831..ca71196670 100644 --- a/client/src/lib/shotContinuity.js +++ b/client/src/lib/shotContinuity.js @@ -1,94 +1,16 @@ -// Client mirror of the deterministic shot-continuity primitives in -// server/lib/editorial/shotContinuity.js (#1315). The two detectors -// (`findAxisReversals`, `findShotTypeMonotony`) are kept byte-for-byte in sync -// with the server — the server `visual.shot-continuity` editorial check is -// authoritative and tested in server/lib/editorial/shotContinuity.test.js. +// The inline, render-gating shot-continuity warnings for the storyboards / +// episode-video stages (#1468) — shown before a render so the user sees a +// 180°-rule axis jump or shot-type monotony without a round-trip through an +// editorial-checks run. // -// This copy powers the storyboards / episode-video stages' INLINE pre-render -// warnings (#1468) so a user sees a 180°-rule axis jump or shot-type monotony -// BEFORE spending render time — without a round-trip through an editorial-checks -// run. Mirrors the inline comic-lettering warning pattern (#1313) in -// client/src/lib/letteringDensity.js. The warning composer (`sceneShotWarnings`) -// below is client-only (the server emits findings in a different shape); port any -// change to the two shared detectors to BOTH sides verbatim. +// The two deterministic detectors are re-exported from +// server/lib/editorial/shotContinuity.js (#1315), which the authoritative +// `visual.shot-continuity` editorial check also runs, so the inline warning and +// the manuscript-review finding can never disagree. Only the composer below is +// client-only (the server emits findings in a different shape). +import { findAxisReversals, findShotTypeMonotony } from '../../../server/lib/editorial/shotContinuity.js'; -// Two directions are an axis reversal only when both are decided AND opposite. -// `neutral` (head-on / ambiguous) has no axis to cross, so it never reverses. -function isAxisReversal(a, b) { - if (a == null || b == null) return false; - if (a === 'neutral' || b === 'neutral') return false; - return a !== b; // one 'left', one 'right' -} - -// Shots of a scene as a safe array (an older/peer-synced scene may carry a -// non-array `shots`). Each element is passed through untouched — the caller -// type-guards the fields it reads. -function sceneShots(scene) { - return Array.isArray(scene?.shots) ? scene.shots : []; -} - -/** - * Detect 180-degree-rule axis reversals across continuity-linked shot pairs in - * one scene. For every shot that chains from an earlier shot in the same scene, - * compare their `screenDirection`; a decided-and-opposite pair is flagged. - * - * @param {object} scene a storyboard scene with `shots[]` - * @returns {Array<{ fromId, toId, fromDirection, toDirection, fromDescription, toDescription }>} - */ -export function findAxisReversals(scene) { - const shots = sceneShots(scene); - if (shots.length < 2) return []; - // Shot ids are unique on the extract path (sanitizeShot synthesizes them) but - // the route doesn't enforce cross-shot uniqueness, so a hand-edited scene could - // carry a duplicate id — last-wins here, matching how a continuity ref would - // resolve anyway. Acceptable under the check's high-precision / under-flag design. - const byId = new Map(); - for (const s of shots) { - if (s && typeof s === 'object' && typeof s.id === 'string' && s.id) byId.set(s.id, s); - } - const out = []; - for (const shot of shots) { - if (!shot || typeof shot !== 'object') continue; - const fromId = typeof shot.continuityFromShotId === 'string' ? shot.continuityFromShotId : null; - if (!fromId) continue; - const prior = byId.get(fromId); - if (!prior || prior === shot) continue; - const fromDir = typeof prior.screenDirection === 'string' ? prior.screenDirection : null; - const toDir = typeof shot.screenDirection === 'string' ? shot.screenDirection : null; - if (!isAxisReversal(fromDir, toDir)) continue; - out.push({ - fromId, - toId: typeof shot.id === 'string' ? shot.id : '', - fromDirection: fromDir, - toDirection: toDir, - fromDescription: typeof prior.description === 'string' ? prior.description : '', - toDescription: typeof shot.description === 'string' ? shot.description : '', - }); - } - return out; -} - -/** - * Detect shot-type monotony in one scene: enough classified shots that they ALL - * share a single `shotType`. Returns the monotony descriptor or null. - * - * @param {object} scene - * @param {{ minClassified?: number }} [opts] minClassified — floored at 2; - * default 3 (mirrors the check's `minShotsForMonotony` default). - * @returns {{ shotType: string, classifiedCount: number } | null} - */ -export function findShotTypeMonotony(scene, opts = {}) { - const minClassified = Math.max(2, Number.isInteger(opts.minClassified) ? opts.minClassified : 3); - const shots = sceneShots(scene); - const types = []; - for (const s of shots) { - if (s && typeof s === 'object' && typeof s.shotType === 'string' && s.shotType) types.push(s.shotType); - } - if (types.length < minClassified) return null; - const first = types[0]; - if (!types.every((t) => t === first)) return null; - return { shotType: first, classifiedCount: types.length }; -} +export { findAxisReversals, findShotTypeMonotony } from '../../../server/lib/editorial/shotContinuity.js'; // Screen-direction → reader-facing label, mirroring the server check's // DIRECTION_LABEL so the inline warning and the editorial-run finding read the same. diff --git a/client/src/lib/shotGrammar.js b/client/src/lib/shotGrammar.js index 6747ec0929..e7658cb4a5 100644 --- a/client/src/lib/shotGrammar.js +++ b/client/src/lib/shotGrammar.js @@ -1,28 +1,14 @@ -// Client mirror of server/lib/shotGrammar.js (#1315) — the canonical controlled -// vocabularies for a storyboard shot's camera framing (`shotType`) and on-screen -// direction (`screenDirection`). The server module is authoritative (it also -// carries the LLM/UI normalizers); this copy holds only the enums + reader-facing -// labels the storyboards shot-grammar editor (#1468) needs to populate its selects. -// Keep SHOT_TYPES / SCREEN_DIRECTIONS in sync with the server verbatim — the route -// validates against the server enum (storyboardShotSchema), so a drifted client -// value would 400 on save. +/** + * Storyboard shot-grammar display labels, over the controlled vocabularies in + * `server/lib/shotGrammar.js` (#1315). + * + * `SHOT_TYPES` / `SCREEN_DIRECTIONS` are re-exported from the server leaf rather + * than copied, so a hand-set value in the storyboards editor always matches what + * `storyboardShotSchema` accepts. The label maps below are client-only + * presentation, keyed off those two enums. + */ +export { SHOT_TYPES, SCREEN_DIRECTIONS } from '../../../server/lib/shotGrammar.js'; -// Camera framing / size. Same membership + order as the server SHOT_TYPES. -export const SHOT_TYPES = Object.freeze([ - 'extreme-wide', - 'wide', - 'medium', - 'close', - 'extreme-close', - 'over-the-shoulder', - 'two-shot', - 'pov', -]); - -// On-screen direction the subject faces / moves. -export const SCREEN_DIRECTIONS = Object.freeze(['left', 'right', 'neutral']); - -// Reader-facing labels for the editor selects. Keys are the canonical tokens. export const SHOT_TYPE_LABELS = Object.freeze({ 'extreme-wide': 'Extreme wide', wide: 'Wide / establishing', diff --git a/client/src/lib/slashdoCatalog.js b/client/src/lib/slashdoCatalog.js index ec912f90bb..feba9be073 100644 --- a/client/src/lib/slashdoCatalog.js +++ b/client/src/lib/slashdoCatalog.js @@ -1,23 +1,21 @@ /** - * Client mirror of `server/lib/slashdoCatalog.js` — which bundled slashdo - * workflows PortOS offers as a one-click agent run (#3114). + * How the Agent Operations panel renders the bundled slashdo workflows (#3114). * - * The server is the source of truth for WHICH workflows exist and what each one - * does; this mirror carries only what the client renders (description, app-type - * gate, drawer flag) plus the button styling the server has no business knowing. - * A parity test in `server/lib/slashdoCatalog.test.js` asserts the two lists agree - * on command / description / appTypes / configurable — so adding a workflow - * server-side without mirroring it fails the suite rather than silently leaving - * the Agent Operations panel a workflow short. - * - * (The import direction is one-way, matching the `constants.js` app-type mirror: - * this module is dependency-free so a server test can import it, while the server - * catalog isn't reachable from a Vite client build.) + * WHICH workflows exist, what each does, and which app types each applies to are + * defined once in `server/lib/slashdoCatalog.js` and read from there — the panel + * cannot be a workflow short, and a description cannot say two different things + * on the two sides. What stays here is the button styling the server has no + * business knowing: `WORKFLOW_TONE` maps a command to a Tailwind class set, and + * `SLASHDO_WORKFLOWS` is the server list decorated with it. */ +import { SLASHDO_WORKFLOWS as SERVER_WORKFLOWS, slashdoWorkflowAppliesTo } from '../../../server/lib/slashdoCatalog.js'; + +export { SLASHDO_APP_TYPES } from '../../../server/lib/slashdoCatalog.js'; /** - * slashdo's command namespace. Mirrors `SLASHDO_NAMESPACE` in - * `server/lib/slashdoInvocation.js`. + * slashdo's command namespace. Kept local rather than imported from + * `server/lib/slashdoInvocation.js`, which owns it: that module reaches for the + * provider registry and is not importable from a browser bundle. */ export const SLASHDO_NAMESPACE = 'do'; @@ -34,13 +32,6 @@ export function slashdoLabel(command) { return `/${SLASHDO_NAMESPACE}:${command}`; } -/** App-type gates — mirrors SLASHDO_APP_TYPES on the server. */ -export const SLASHDO_APP_TYPES = Object.freeze({ - ANY: 'any', - SWIFT: 'swift', - NON_SWIFT: 'non-swift', -}); - const CLASSES = { success: 'bg-port-success/20 text-port-success hover:bg-port-success/30 border-port-success/30', accent: 'bg-port-accent/20 text-port-accent hover:bg-port-accent/30 border-port-accent/30', @@ -51,6 +42,22 @@ const CLASSES = { slate: 'bg-slate-500/20 text-slate-300 hover:bg-slate-500/30 border-slate-500/30', }; +// Button tone per workflow. A command with no row falls back to `slate`, so a +// workflow added server-side renders as a working (if unstyled) button rather +// than as `className="… undefined …"`. +const WORKFLOW_TONE = { + 'plan-task': CLASSES.slate, + next: CLASSES.blue, + replan: CLASSES.cyan, + review: CLASSES.accent, + push: CLASSES.success, + release: CLASSES.purple, + better: CLASSES.warning, + 'better-swift': CLASSES.warning, + depfree: CLASSES.slate, + scan: CLASSES.slate, +}; + /** * @typedef {Object} SlashdoWorkflowButton * @property {string} command - bare slashdo command name (`plan-task`) @@ -62,69 +69,10 @@ const CLASSES = { */ /** @type {ReadonlyArray} */ -export const SLASHDO_WORKFLOWS = Object.freeze([ - { - command: 'plan-task', - description: 'Investigate the codebase and file a decision-complete issue', - appTypes: SLASHDO_APP_TYPES.ANY, - classes: CLASSES.slate, - }, - { - command: 'next', - description: "Claim the next unclaimed work item (per the app's Work Tracker) and ship a PR", - appTypes: SLASHDO_APP_TYPES.ANY, - configurable: true, - classes: CLASSES.blue, - }, - { - command: 'replan', - description: 'Audit the backlog, archive completed items, prune stale work', - appTypes: SLASHDO_APP_TYPES.ANY, - classes: CLASSES.cyan, - }, - { - command: 'review', - description: 'Deep code review of the changed files', - appTypes: SLASHDO_APP_TYPES.ANY, - classes: CLASSES.accent, - }, - { - command: 'push', - description: 'Commit and push all work with a changelog entry', - appTypes: SLASHDO_APP_TYPES.ANY, - classes: CLASSES.success, - }, - { - command: 'release', - description: 'Create a release PR', - appTypes: SLASHDO_APP_TYPES.ANY, - classes: CLASSES.purple, - }, - { - command: 'better', - description: 'Run a DevSecOps audit and remediation pass', - appTypes: SLASHDO_APP_TYPES.NON_SWIFT, - classes: CLASSES.warning, - }, - { - command: 'better-swift', - description: 'Run a SwiftUI DevSecOps audit and remediation pass', - appTypes: SLASHDO_APP_TYPES.SWIFT, - classes: CLASSES.warning, - }, - { - command: 'depfree', - description: 'Audit dependencies and remove the unnecessary ones', - appTypes: SLASHDO_APP_TYPES.ANY, - classes: CLASSES.slate, - }, - { - command: 'scan', - description: 'Read-only safety audit — malware patterns, network calls, vulnerable deps', - appTypes: SLASHDO_APP_TYPES.ANY, - classes: CLASSES.slate, - }, -]); +export const SLASHDO_WORKFLOWS = Object.freeze(SERVER_WORKFLOWS.map((workflow) => Object.freeze({ + ...workflow, + classes: WORKFLOW_TONE[workflow.command] || CLASSES.slate, +}))); /** * The workflows launchable for one app, filtered by its Swift-ness. `better` and @@ -134,7 +82,5 @@ export const SLASHDO_WORKFLOWS = Object.freeze([ * @returns {SlashdoWorkflowButton[]} */ export function slashdoWorkflowsForApp(isSwiftApp) { - return SLASHDO_WORKFLOWS.filter(w => - w.appTypes === SLASHDO_APP_TYPES.ANY - || (isSwiftApp ? w.appTypes === SLASHDO_APP_TYPES.SWIFT : w.appTypes === SLASHDO_APP_TYPES.NON_SWIFT)); + return SLASHDO_WORKFLOWS.filter((workflow) => slashdoWorkflowAppliesTo(workflow, isSwiftApp)); } diff --git a/client/src/lib/textUtils.js b/client/src/lib/textUtils.js index 9e7f65629e..5a67bbf78a 100644 --- a/client/src/lib/textUtils.js +++ b/client/src/lib/textUtils.js @@ -1,26 +1,11 @@ -// Mirror of server/lib/textUtils.js — the client-side home for the RegExp -// escape. Partial by design: only `escapeRegExp` is mirrored, because it is the -// only member the browser bundle has a caller for. `countWords` already mirrors -// through `client/src/utils/formatters.js`, and `trimTo`/`kebabCase` have no -// client caller — adding them here would ship dead bytes and invite drift on -// helpers nothing checks. The server copy is authoritative; the parity pin in -// `server/lib/textUtils.test.js` is the contract. -// -// It exists because the browser cannot import from `server/`, so before this -// module every client caller re-inlined the character class — the exact rot the -// server-side guard closed on its own tree (#5790). That guard now scans -// `client/src` too, so a fresh private copy on this side fails the suite. - /** - * Escape a string for literal use inside a RegExp. + * The RegExp escape, from the module that owns it on both sides. * - * This is the ONE client copy — import it, never re-inline the character class. + * Only `escapeRegExp` is re-exported — it is the only member the browser bundle + * has a caller for, and a flat `export *` would put the server’s `countWords` + * in the client barrel beside the one in `utils/formatters.js`. * - * Non-string input is coerced rather than throwing, matching the server: the - * callers escape user-supplied tokens (LoRA trigger words, canon character - * names, ⌘K queries) on the way into `new RegExp(...)`, where a TypeError would - * blank a rendered surface instead of simply not matching. + * Imported rather than copied so the two runtimes cannot drift; the file stays so + * every `lib/textUtils` import path in the client is unchanged. */ -export function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} +export { escapeRegExp } from '../../../server/lib/textUtils.js'; diff --git a/client/src/lib/tribeCadence.contract.test.js b/client/src/lib/tribeCadence.contract.test.js deleted file mode 100644 index 36b1ee6387..0000000000 --- a/client/src/lib/tribeCadence.contract.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - cadenceStatus as clientCadenceStatus, - daysSinceDate as clientDaysSinceDate, - DEFAULT_CADENCE_DAYS as CLIENT_DEFAULT, - SOON_WINDOW_DAYS as CLIENT_SOON, -} from './tribeCadence.js'; -// The server copy is authoritative; the client copy is a mirror. Importing both -// here (vitest resolves the cross-boundary path, same as providers.test.js) and -// asserting identical output is the anti-drift guarantee for issue #2060 — if -// either file's cadence rules change without the other, this suite fails. -import { - cadenceStatus as serverCadenceStatus, - daysSinceDate as serverDaysSinceDate, - DEFAULT_CADENCE_DAYS as SERVER_DEFAULT, - SOON_WINDOW_DAYS as SERVER_SOON, -} from '../../../server/lib/tribeCadence.js'; - -// N days before today as a YYYY-MM-DD string, so the suite is date-independent. -// Build the string from LOCAL calendar fields — `daysSinceDate` parses the -// `YYYY-MM-DD` in local time, so using `toISOString()` (UTC) here would shift -// the date across the UTC/local day boundary in the evening and make the -// elapsed-day math off-by-one (timezone-dependent flake). -function daysAgo(n) { - const d = new Date(); - d.setDate(d.getDate() - n); - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; -} - -describe('tribeCadence — client/server mirror is byte-identical', () => { - it('exposes identical tuning constants', () => { - expect(CLIENT_DEFAULT).toBe(SERVER_DEFAULT); - expect(CLIENT_SOON).toBe(SERVER_SOON); - expect(CLIENT_DEFAULT).toBe(45); - expect(CLIENT_SOON).toBe(7); - }); - - const entities = [ - { ring: 'external', lastContact: daysAgo(999), cadenceDays: 7 }, // external excluded - { ring: 'core', lastContact: null, cadenceDays: 21 }, // missing - { ring: 'core', lastContact: undefined, cadenceDays: 21 }, // missing (undefined) - { ring: 'support', lastContact: 'not-a-date', cadenceDays: 7 }, // unparseable → missing - { ring: 'support', lastContact: daysAgo(10), cadenceDays: 7 }, // overdue - { ring: 'core', lastContact: daysAgo(21), cadenceDays: 21 }, // 0 remaining → soon boundary - { ring: 'core', lastContact: daysAgo(14), cadenceDays: 21 }, // 7 remaining → soon upper boundary - { ring: 'core', lastContact: daysAgo(13), cadenceDays: 21 }, // 8 remaining → steady - { ring: 'village', lastContact: daysAgo(2), cadenceDays: 90 }, // steady - { ring: 'tribe', lastContact: daysAgo(45), cadenceDays: 0 }, // cadenceDays 0 → default 45 - ]; - - it.each(entities)('cadenceStatus matches across boundary for %o', (entity) => { - expect(clientCadenceStatus(entity)).toEqual(serverCadenceStatus(entity)); - }); - - it.each([null, undefined, '', 'garbage', daysAgo(0), daysAgo(3)])( - 'daysSinceDate matches across boundary for %o', - (value) => { - expect(clientDaysSinceDate(value)).toBe(serverDaysSinceDate(value)); - }, - ); -}); - -describe('tribeCadence — cadence rules (semantics preserved from #2032)', () => { - it('external members are excluded from care (never nagged)', () => { - expect(clientCadenceStatus({ ring: 'external', lastContact: daysAgo(999), cadenceDays: 7 })) - .toEqual({ state: 'external', daysRemaining: null, daysOverdue: 0 }); - }); - - it('distinguishes missing (never contacted) from overdue', () => { - const missing = clientCadenceStatus({ ring: 'core', lastContact: null, cadenceDays: 21 }); - expect(missing.state).toBe('missing'); - expect(missing.daysRemaining).toBeNull(); - expect(missing.daysOverdue).toBeNull(); // missing sorts above dated-overdue - - const overdue = clientCadenceStatus({ ring: 'support', lastContact: daysAgo(10), cadenceDays: 7 }); - expect(overdue.state).toBe('overdue'); - expect(overdue.daysOverdue).toBe(3); - }); - - it('treats <=7 days remaining as soon, >7 as steady', () => { - expect(clientCadenceStatus({ ring: 'core', lastContact: daysAgo(14), cadenceDays: 21 }).state).toBe('soon'); - expect(clientCadenceStatus({ ring: 'core', lastContact: daysAgo(13), cadenceDays: 21 }).state).toBe('steady'); - }); -}); -// @vitest-environment node diff --git a/client/src/lib/tribeCadence.js b/client/src/lib/tribeCadence.js index 08c4d58f64..86ffd91307 100644 --- a/client/src/lib/tribeCadence.js +++ b/client/src/lib/tribeCadence.js @@ -1,36 +1,13 @@ -// Mirror of server/lib/tribeCadence.js — the single source of truth for the -// Tribe care cadence rules. The server copy is authoritative; port logic -// changes verbatim and keep the two byte-identical in behavior. The -// cross-boundary contract test (client/src/lib/tribeCadence.contract.test.js) -// imports BOTH this mirror and the server module and fails CI if they drift. -// The client `contactStatus` (lib/tribe.js) layers presentation (label/tone) -// on top of `cadenceStatus`; it must not re-implement the state machine. - -// The four inner rings owe a care cadence; `external` is never nagged. -export const DEFAULT_CADENCE_DAYS = 45; -// A member with <= this many days left before their next check-in is "soon". -export const SOON_WINDOW_DAYS = 7; - -// Whole days from an ISO date (YYYY-MM-DD…) to today, or null when unparseable. -export function daysSinceDate(dateStr) { - if (!dateStr) return null; - const start = new Date(`${String(dateStr).slice(0, 10)}T00:00:00`); - if (Number.isNaN(start.getTime())) return null; - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - return Math.floor((today - start) / 86400000); -} - -// Cadence health for a tribe member: external / missing / overdue / soon / -// steady. `daysRemaining` is cadenceDays - elapsed (negative once overdue); -// null when there's no recorded last contact. `daysOverdue` is 0 unless -// overdue, and null for a `missing` member. External members carry no cadence. -export function cadenceStatus(entity) { - if (entity.ring === 'external') return { state: 'external', daysRemaining: null, daysOverdue: 0 }; - const elapsed = daysSinceDate(entity.lastContact); - if (elapsed == null) return { state: 'missing', daysRemaining: null, daysOverdue: null }; - const daysRemaining = Number(entity.cadenceDays || DEFAULT_CADENCE_DAYS) - elapsed; - if (daysRemaining < 0) return { state: 'overdue', daysRemaining, daysOverdue: Math.abs(daysRemaining) }; - if (daysRemaining <= SOON_WINDOW_DAYS) return { state: 'soon', daysRemaining, daysOverdue: 0 }; - return { state: 'steady', daysRemaining, daysOverdue: 0 }; -} +/** + * Tribe check-in cadence vocabulary. + * + * Re-export of `server/lib/tribeCadence.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/tribeCadence` import path in the client is unchanged. + */ +export { + DEFAULT_CADENCE_DAYS, + SOON_WINDOW_DAYS, + cadenceStatus, + daysSinceDate, +} from '../../../server/lib/tribeCadence.js'; diff --git a/client/src/lib/videoReferenceModes.js b/client/src/lib/videoReferenceModes.js index 98a9e7c3b4..347243d216 100644 --- a/client/src/lib/videoReferenceModes.js +++ b/client/src/lib/videoReferenceModes.js @@ -1,141 +1,21 @@ /** - * Mirror of server/lib/videoReferenceModes.js — keep the LOGIC byte-for-byte in - * sync (server/lib/videoReferenceModes.mirror.test.js is the contract; it - * strips comments, so this commentary may diverge but nothing else may). + * Video reference-mode vocabulary and its per-mode rules. * - * Vites fs.allow does not cross the server/ boundary, so the client carries its - * own copy of the i2v reference-mode table. The page uses it to decide which - * options to offer, what promise to print next to the source-image picker, and - * what the effective conditioning strength will be — a drifted copy would - * promise the user a render the server refuses, or worse, one it silently - * downgrades. + * Re-export of `server/lib/videoReferenceModes.js` — the one definition of this rule, + * imported rather than copied so the two runtimes cannot drift. The file stays + * so every `lib/videoReferenceModes` import path in the client is unchanged. */ - -// Ordered — the UI renders the picker in this order and `anchor` is first -// because it is the default. -export const I2V_REFERENCE_MODES = Object.freeze(['anchor', 'inspire']); - -export const DEFAULT_I2V_REFERENCE_MODE = 'anchor'; - -// The promise each mode makes, in the words the UI shows the user. Kept beside -// the rule table rather than in the component so the server's rejection message -// and the panel's helper text can't describe two different contracts. -export const I2V_REFERENCE_MODE_OPTIONS = Object.freeze([ - Object.freeze({ - value: 'anchor', - label: 'Anchor', - promise: 'The reference is frame one — the clip animates outward from those exact pixels.', - }), - Object.freeze({ - value: 'inspire', - label: 'Inspire', - promise: 'The reference guides subject and style. Frame one is generated, so it resembles the image without reproducing it.', - }), -]); - -// Runtimes that can honor each mode. `null` means "every runtime" — `anchor` is -// what an i2v pipeline does by construction. `inspire` needs per-image -// conditioning strength, which today only the LTX-2.5 pin exposes -// (ImageConditioningInput on generate_and_save); the 2.3 pin shares the family -// predicate but not that API, so it is NOT listed. -export const I2V_REFERENCE_MODE_RUNTIMES = Object.freeze({ - anchor: null, - inspire: Object.freeze(['ltx25']), -}); - -// Conditioning strength applied to a loose reference when the user leaves the -// Image Strength slider untouched. Low enough that the first frame is visibly -// re-generated rather than reproduced — which is exactly the promise `inspire` -// makes. An explicit slider value always wins. -export const INSPIRE_DEFAULT_IMAGE_STRENGTH = 0.35; - -/** - * `''` / `null` / `undefined` all mean "not set" and resolve to the default. - * Anything else is returned VERBATIM so an unknown value reaches the gate below - * and is rejected there — collapsing garbage into `anchor` would turn a typo - * into a silently different render (AGENTS: sentinel + validate, never let - * invalid share a value with valid). - */ -export const normalizeI2vReferenceMode = (value) => ( - value == null || value === '' ? DEFAULT_I2V_REFERENCE_MODE : value -); - -export const isDefaultI2vReferenceMode = (value) => ( - normalizeI2vReferenceMode(value) === DEFAULT_I2V_REFERENCE_MODE -); - -export const isKnownI2vReferenceMode = (value) => ( - I2V_REFERENCE_MODES.includes(normalizeI2vReferenceMode(value)) -); - -/** Can `runtime` deliver what `value` promises? Unknown modes are never supported. */ -export const runtimeSupportsI2vReferenceMode = (runtime, value) => { - const mode = normalizeI2vReferenceMode(value); - if (!I2V_REFERENCE_MODES.includes(mode)) return false; - const allowed = I2V_REFERENCE_MODE_RUNTIMES[mode]; - return allowed === null || allowed.includes(runtime); -}; - -export const i2vReferenceModeLabel = (value) => ( - I2V_REFERENCE_MODE_OPTIONS.find((o) => o.value === normalizeI2vReferenceMode(value))?.label - || normalizeI2vReferenceMode(value) -); - -/** - * The effective first-frame conditioning strength for a render, or `null` for - * "let the pipeline apply its own default". - * - * An explicit slider value is honored on BOTH modes — a user who wants a firmer - * loose reference (or a softer anchor) gets it. Only the *unset* case differs: - * `inspire` substitutes its own low default, because "no value" there still has - * to mean "don't reproduce frame one", while `anchor` keeps deferring to the - * pipeline exactly as it did before this field existed. - */ -export const resolveI2vReferenceStrength = (referenceMode, imageStrength) => { - if (imageStrength != null && imageStrength !== '') { - const explicit = Number(imageStrength); - if (Number.isFinite(explicit)) return explicit; - } - return normalizeI2vReferenceMode(referenceMode) === 'inspire' - ? INSPIRE_DEFAULT_IMAGE_STRENGTH - : null; -}; - -/** - * The one reference-mode rule, as a pure `{ code, message } | null`. - * - * `anchor` is always legal, so a request that never touched this field can - * never be rejected by it. Everything else has to clear three gates: the value - * is known, the render is actually image-to-video with a source image, and the - * model's runtime can honor the promise. - * - * @param {object} opts - * @param {object} [opts.model] - registry entry (`name`, `runtime`) - * @param {string} [opts.mode] - RESOLVED semantic mode ('image', 'text', 'fflf', …) - * @param {string} [opts.referenceMode]- requested reference mode - * @param {boolean} [opts.hasFirstImage] - a first-frame conditioning image is present - */ -export const i2vReferenceModeViolation = ({ model, mode, referenceMode, hasFirstImage = true } = {}) => { - const requested = normalizeI2vReferenceMode(referenceMode); - if (requested === DEFAULT_I2V_REFERENCE_MODE) return null; - if (!I2V_REFERENCE_MODES.includes(requested)) { - return { - code: 'I2V_REFERENCE_MODE_UNKNOWN', - message: `Unknown reference mode "${requested}" — expected one of ${I2V_REFERENCE_MODES.join(', ')}.`, - }; - } - const label = i2vReferenceModeLabel(requested); - if (mode !== 'image' || !hasFirstImage) { - return { - code: 'I2V_REFERENCE_MODE_REQUIRES_IMAGE', - message: `${label} reference mode applies to image-to-video only — switch to image mode with a source image, or use ${i2vReferenceModeLabel(DEFAULT_I2V_REFERENCE_MODE)}.`, - }; - } - if (!runtimeSupportsI2vReferenceMode(model?.runtime, requested)) { - return { - code: 'I2V_REFERENCE_MODE_UNSUPPORTED', - message: `${model?.name || 'This model'} cannot honor the ${label} reference mode — its runtime ("${model?.runtime || 'mlx_video'}") pins the reference as frame one. Choose an LTX-2.5 model, or switch to ${i2vReferenceModeLabel(DEFAULT_I2V_REFERENCE_MODE)}.`, - }; - } - return null; -}; +export { + DEFAULT_I2V_REFERENCE_MODE, + I2V_REFERENCE_MODES, + I2V_REFERENCE_MODE_OPTIONS, + I2V_REFERENCE_MODE_RUNTIMES, + INSPIRE_DEFAULT_IMAGE_STRENGTH, + i2vReferenceModeLabel, + i2vReferenceModeViolation, + isDefaultI2vReferenceMode, + isKnownI2vReferenceMode, + normalizeI2vReferenceMode, + resolveI2vReferenceStrength, + runtimeSupportsI2vReferenceMode, +} from '../../../server/lib/videoReferenceModes.js'; diff --git a/client/src/lib/youtubeUrl.js b/client/src/lib/youtubeUrl.js index f29d83430e..986da38ee3 100644 --- a/client/src/lib/youtubeUrl.js +++ b/client/src/lib/youtubeUrl.js @@ -1,35 +1,15 @@ /** - * Single-video YouTube URL detection — MIRROR of `YOUTUBE_VIDEO_URL_RE` in - * `server/lib/youtubeUrl.js` (authoritative there). + * The YouTube ingest options Quick Capture offers, over the canonical + * single-video URL rule in `server/lib/youtubeUrl.js`. * - * The Quick Capture box swaps its whole submit path (brain capture → YouTube - * ingest) based on this predicate, and reveals the ingest options panel from it, - * so a looser client answer would offer options for a URL the server refuses. - * Port any change from the server copy verbatim. - * - * Deliberately narrow: playlists, channels, and `/@handle` pages are NOT - * matched — a paste that would have yt-dlp pull 300 videos should fall through - * to normal link capture, not silently start a batch download. + * `isYoutubeVideoUrl` / `youtubeVideoId` are re-exported from that leaf rather + * than copied. Quick Capture swaps its whole submit path (brain capture → + * YouTube ingest) on the predicate and reveals the options panel from it, so a + * looser client answer would offer options for a URL the server refuses. The + * throwing form the routes use lives in `server/lib/youtubeUrlAssert.js`, which + * this deliberately does not reach for. */ - -const SINGLE_VIDEO_RE = - /^https?:\/\/(www\.|m\.|music\.)?(youtube\.com\/(watch\?[^\s#]*\bv=[\w-]{6,}|shorts\/[\w-]{6,}|live\/[\w-]{6,}|embed\/[\w-]{6,})|youtu\.be\/[\w-]{6,})/i; - -/** The video id in a YouTube URL, or null. Mirrors `youtubeVideoIdFromUrl` in `server/lib/youtubeUrl.js`. */ -export function youtubeVideoId(url) { - if (!url) return null; - const s = String(url).trim(); - const vParam = /[?&]v=([A-Za-z0-9_-]{6,20})/.exec(s); - if (vParam) return vParam[1]; - const pathId = /(?:youtu\.be\/|\/shorts\/|\/embed\/|\/live\/|\/v\/)([A-Za-z0-9_-]{6,20})/.exec(s); - return pathId ? pathId[1] : null; -} - -/** True when `text` is a single-video YouTube URL the ingest endpoint accepts. */ -export function isYoutubeVideoUrl(text) { - const trimmed = (text ?? '').trim(); - return SINGLE_VIDEO_RE.test(trimmed) && !!youtubeVideoId(trimmed); -} +export { isYoutubeVideoUrl, youtubeVideoId } from '../../../server/lib/youtubeUrl.js'; /** * The three artifacts an ingest can produce, as ONE table. diff --git a/client/src/pages/Loras.jsx b/client/src/pages/Loras.jsx index 3c869d177a..78555f9771 100644 --- a/client/src/pages/Loras.jsx +++ b/client/src/pages/Loras.jsx @@ -23,7 +23,7 @@ import { useConfirmDelete } from '../hooks/useConfirmDelete'; import useDownloadPreflightConfirm from '../hooks/useDownloadPreflightConfirm'; import { formatBytes } from '../utils/formatters'; import { RUNNER_FAMILIES, VIDEO_LORA_FAMILIES, isVideoLoraFamily } from '../lib/runnerFamilies'; -import { LORA_EFFECT_STATUSES, formatLoraEffect, loraEffectBadge } from '../lib/loraEffect'; +import { LORA_EFFECT_STATUSES, loraEffectDetail, loraEffectBadge } from '../lib/loraEffect'; import { listLorasFull, installLoraFromCivitai, @@ -1220,13 +1220,13 @@ function LoraCard({ lora, onDelete, onMeasured, deleting, deleteConfirm }) { // entry, so the badge survives this card being unmounted by a filter change. const effect = lora.effectReport || null; const [checkingEffect, setCheckingEffect] = useState(false); - const effectSummary = formatLoraEffect(effect); + const effectSummary = loraEffectDetail(effect); const runEffectCheck = async () => { setCheckingEffect(true); await probeLoraEffect(lora.filename, { force: true, silent: true }) .then((report) => { onMeasured?.(lora.filename, report); - const summary = formatLoraEffect(report); + const summary = loraEffectDetail(report); if (report?.status === LORA_EFFECT_STATUSES.ZERO) { toast.error(`${displayName} has no measurable effect — a render would look as if it were off`); } else if (report?.status === LORA_EFFECT_STATUSES.OK) { @@ -1299,7 +1299,7 @@ function LoraCard({ lora, onDelete, onMeasured, deleting, deleteConfirm }) { {loraEffectBadge(effect.status).label} - {/* formatLoraEffect returns null when the badge already says + {/* loraEffectDetail returns null when the badge already says everything, so a reason-less verdict doesn't render as "Unreadable — Unreadable". */} {effectSummary && — {effectSummary}} diff --git a/docs/PORTS.md b/docs/PORTS.md index ce8aa73212..36fb769c43 100644 --- a/docs/PORTS.md +++ b/docs/PORTS.md @@ -7,7 +7,7 @@ PortOS uses a contiguous port allocation scheme to make it easy to understand wh ### Convention 1. **Contiguous Ranges**: Each app should use a contiguous block of ports -2. **Labeled Ports**: Define all ports in the top-level `PORTS` object in `ecosystem.config.cjs` (mirrored — manually kept in sync — in `server/lib/ports.js`, since the ESM server can't `require()` the CommonJS config); the per-process label map for PM2 processes lives in `server/services/apps.js`. Infrastructure dependencies (such as PostgreSQL on 5561) are provisioned via `scripts/setup-db.js` / Docker Compose rather than registered as PM2 processes in `apps.js`. The mirror carries every port literal, including both PostgreSQL ports; the config's mode-dependent `POSTGRES` (resolved from `PGMODE` at load time) is exposed in the mirror as `resolvePostgresPort(pgMode)` over the `POSTGRES_NATIVE` / `POSTGRES_DOCKER` literals, so `server/lib/ports.js` stays free of filesystem reads. `server/lib/ports.test.js` fails if the two drift apart. The browser bundle can't import either file, so `client/src/lib/ports.js` carries a third, deliberately minimal mirror of just the UI-facing subset (`API`, `API_LOCAL`, `UI`) plus `DEFAULT_PEER_PORT` — use it instead of re-hardcoding a port literal in client code; `client/src/lib/ports.parity.test.js` fails if it drifts +2. **Labeled Ports**: Define all ports in the top-level `PORTS` object in `ecosystem.config.cjs` (mirrored — manually kept in sync — in `server/lib/ports.js`, since the ESM server can't `require()` the CommonJS config); the per-process label map for PM2 processes lives in `server/services/apps.js`. Infrastructure dependencies (such as PostgreSQL on 5561) are provisioned via `scripts/setup-db.js` / Docker Compose rather than registered as PM2 processes in `apps.js`. The mirror carries every port literal, including both PostgreSQL ports; the config's mode-dependent `POSTGRES` (resolved from `PGMODE` at load time) is exposed in the mirror as `resolvePostgresPort(pgMode)` over the `POSTGRES_NATIVE` / `POSTGRES_DOCKER` literals, so `server/lib/ports.js` stays free of filesystem reads. `server/lib/ports.test.js` fails if the two drift apart. `client/src/lib/ports.js` re-exports the ESM mirror for the browser bundle (there is no third copy) — use it instead of re-hardcoding a port literal in client code. That re-export is why `server/lib/ports.js` reads no `process.env` at module scope: the env-derived `PORTOS_UI_URL` / `PORTOS_API_URL` live in `server/lib/portosUrls.js` 3. **No Gaps**: Avoid leaving gaps between port allocations within an app ### Port Labels diff --git a/scripts/ci-test-plan.js b/scripts/ci-test-plan.js index 335f2ef239..9d1447e7ba 100644 --- a/scripts/ci-test-plan.js +++ b/scripts/ci-test-plan.js @@ -195,10 +195,15 @@ export const ALWAYS_RUN_TESTS = [ // The union-merged catalogs are `.md` to the planner — documentation-only — // so a rebase that doubled a row would otherwise never be re-checked. 'scripts/catalog-merge-union.test.js', + // Walks the client→server/lib import graph; any server/lib file can add a + // Node-only import and break the client build with no edge back to here. + 'scripts/client-server-import-purity.test.js', 'scripts/direct-invocation-drift.test.js', 'scripts/ensure-deps.test.js', 'scripts/node-version-drift.test.js', 'scripts/repo-scan-guards.test.js', + // Whole-tree scanner: any server file can add an import of client source. + 'scripts/server-imports-no-client.test.js', 'scripts/tailnet-identity-leak.test.js', 'server/dependency-overrides.test.js', // Whole-tree scanner: any server file can add a `process.env` read, and @@ -362,13 +367,6 @@ const structuralTestsFor = (changedFiles, trackedSet) => { if (changedFiles.some((path) => /^client\/src\/lib\//.test(path))) { add('client/src/lib/index.test.js'); } - // mirrorCoverage.test.js walks both directories and diffs their README mirror - // catalogs against the actual test files present; it names no file itself, so - // no import edge or basename `git grep` (pathContractTests, see rule 1 above) - // can reach it either. - if (changedFiles.some((path) => /^(?:server|client\/src)\/lib\//.test(path))) { - add('server/lib/mirrorCoverage.test.js'); - } if (changedFiles.some((path) => /^client\/src\/hooks\//.test(path))) { add('client/src/hooks/index.test.js'); } diff --git a/scripts/ci-test-plan.test.js b/scripts/ci-test-plan.test.js index 31749da85f..2206e00fcd 100644 --- a/scripts/ci-test-plan.test.js +++ b/scripts/ci-test-plan.test.js @@ -464,37 +464,23 @@ describe('CI test impact planner', () => { it('reaches a mirror-parity test from either side of the mirror by basename (#6363)', () => { const tracked = [ ...TRACKED, - 'server/lib/seasonStructure.js', - 'server/lib/seasonStructure.mirror.test.js', - 'client/src/lib/seasonStructure.js', + 'server/lib/eidoverseWorldReset.js', + 'server/lib/eidoverseWorldReset.parity.test.js', + 'client/src/lib/eidoverseWorldReset.js', ]; // Both copies share one basename, and the mirror test names the OTHER copy // only by that basename — a mirror test living in server/lib is what a // change to either side must select. const pathContractTests = { - 'server/lib/seasonStructure.js': ['server/lib/seasonStructure.mirror.test.js'], - 'client/src/lib/seasonStructure.js': ['server/lib/seasonStructure.mirror.test.js'], + 'server/lib/eidoverseWorldReset.js': ['server/lib/eidoverseWorldReset.parity.test.js'], + 'client/src/lib/eidoverseWorldReset.js': ['server/lib/eidoverseWorldReset.parity.test.js'], }; - const serverSide = buildCiTestPlan(['server/lib/seasonStructure.js'], { trackedFiles: tracked, pathContractTests }); - expect(serverSide.server.files).toContain('server/lib/seasonStructure.mirror.test.js'); + const serverSide = buildCiTestPlan(['server/lib/eidoverseWorldReset.js'], { trackedFiles: tracked, pathContractTests }); + expect(serverSide.server.files).toContain('server/lib/eidoverseWorldReset.parity.test.js'); - const clientSide = buildCiTestPlan(['client/src/lib/seasonStructure.js'], { trackedFiles: tracked, pathContractTests }); - expect(clientSide.server.files).toContain('server/lib/seasonStructure.mirror.test.js'); - }); - - it('runs mirrorCoverage.test.js whenever a mirrored directory changes, since it names no file (#6363)', () => { - const tracked = [...TRACKED, 'server/lib/mirrorCoverage.test.js']; - - const serverLib = buildCiTestPlan(['server/lib/bufferedSpawn.js'], { trackedFiles: tracked }); - expect(serverLib.server.files).toContain('server/lib/mirrorCoverage.test.js'); - - const clientLib = buildCiTestPlan(['client/src/lib/catalogLinks.js'], { trackedFiles: tracked }); - expect(clientLib.server.files).toContain('server/lib/mirrorCoverage.test.js'); - - // Unrelated directories don't force it. - const unrelated = buildCiTestPlan(['server/services/auth.js'], { trackedFiles: tracked }); - expect(unrelated.server.files).not.toContain('server/lib/mirrorCoverage.test.js'); + const clientSide = buildCiTestPlan(['client/src/lib/eidoverseWorldReset.js'], { trackedFiles: tracked, pathContractTests }); + expect(clientSide.server.files).toContain('server/lib/eidoverseWorldReset.parity.test.js'); }); it('runs the generated-manifest drift tests whenever a server source changes', () => { diff --git a/scripts/client-server-import-purity.test.js b/scripts/client-server-import-purity.test.js new file mode 100644 index 0000000000..6ee5407c14 --- /dev/null +++ b/scripts/client-server-import-purity.test.js @@ -0,0 +1,128 @@ +/** + * Guard: every `server/lib` module the CLIENT imports stays browser-safe. + * + * PortOS shares pure logic between the two runtimes by importing the server + * leaf from the client (`client/src/lib/README.md`), not by copying it. That + * only works while the imported module — and everything it transitively pulls — + * reaches no Node built-in and nothing outside `server/lib`. The failure mode is + * silent at edit time and loud at build time: adding a `crypto` import to a + * module three edges away from `Layout.jsx` breaks `npm run build --prefix + * client` for a reason the diff does not name. + * + * So this walks the real import graph from every `server/lib` module named by a + * client import specifier and fails on the first impure edge, pointing at the + * chain that introduced it. It scans the tracked tree, so nothing imports it and + * CI's import-graph selection cannot reach it — it rides `ALWAYS_RUN_TESTS`. + */ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'child_process'; +import { existsSync, readFileSync } from 'fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SERVER_LIB = join(REPO_ROOT, 'server', 'lib'); + +/** + * True when an absolute path sits inside `server/lib`. Asked through `relative` + * rather than a `startsWith(\`${SERVER_LIB}/\`)` prefix test, which is a + * separator bug: `resolve` hands back backslashes on Windows, so the prefix + * never matched there and every in-tree import read as "outside server/lib". + */ +export const withinServerLib = (target, root = SERVER_LIB) => { + const rel = relative(root, target); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +}; + +/** Every `from '…'` / `import '…'` specifier in one module's source. */ +const specifiersIn = (source) => [ + ...source.matchAll(/(?:^|\n)\s*(?:import|export)[^'"\n]*?from\s*['"]([^'"]+)['"]/g), + ...source.matchAll(/(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g), +].map((match) => match[1]); + +/** The `server/lib/*` modules a client source file imports (specifiers only, not prose). */ +export const clientImportedServerLibModules = (source) => [ + ...source.matchAll(/from\s*['"](?:\.\.\/)+server\/lib\/([\w./-]+\.js)['"]/g), +].map((match) => match[1]); + +const trackedClientSources = execFileSync( + 'git', + ['ls-files', 'client/src/*.js', 'client/src/*.jsx', 'client/src/**/*.js', 'client/src/**/*.jsx'], + { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, +).split('\n').filter(Boolean); + +const entryModules = [...new Set(trackedClientSources.flatMap((rel) => ( + clientImportedServerLibModules(readFileSync(join(REPO_ROOT, rel), 'utf8')) + .map((mod) => join(SERVER_LIB, mod)) +)))]; + +/** + * Walk the graph from `entries`, returning one violation per impure edge: + * `{ chain, specifier, reason }`. A relative specifier that escapes + * `server/lib` and any bare specifier (a Node built-in, or an npm package the + * client bundle has no reason to be handed) both count. + */ +function impureEdges(entries) { + const violations = []; + const seen = new Set(); + const queue = entries.map((file) => ({ file, chain: [relative(REPO_ROOT, file)] })); + while (queue.length) { + const { file, chain } = queue.shift(); + if (seen.has(file)) continue; + seen.add(file); + if (!existsSync(file)) { + violations.push({ chain, specifier: file, reason: 'module does not exist' }); + continue; + } + for (const specifier of specifiersIn(readFileSync(file, 'utf8'))) { + if (!specifier.startsWith('.')) { + violations.push({ chain, specifier, reason: 'non-relative import (Node built-in or npm package)' }); + continue; + } + const target = resolve(dirname(file), specifier); + if (!withinServerLib(target)) { + violations.push({ chain, specifier, reason: 'resolves outside server/lib' }); + continue; + } + queue.push({ file: target, chain: [...chain, relative(REPO_ROOT, target)] }); + } + } + return violations; +} + +describe('server/lib modules imported by the client stay pure (#6364)', () => { + it('finds the client→server import edges to walk', () => { + // Fails loudly if the glob or the specifier pattern stops matching, rather + // than reporting a vacuous pass over zero entry points. + expect(entryModules.length).toBeGreaterThan(10); + }); + + it('detects the shapes it guards, and leaves a pure leaf alone (bypass probe)', () => { + expect(clientImportedServerLibModules("import { a } from '../../../server/lib/x.js';")).toEqual(['x.js']); + expect(clientImportedServerLibModules("import { a } from '../../../server/lib/editorial/y.js';")).toEqual(['editorial/y.js']); + // Prose mentioning the path is not an import. + expect(clientImportedServerLibModules('// mirrors server/lib/x.js')).toEqual([]); + // Containment is asked with `relative`, so it holds under both separators — + // a prefix test on `${SERVER_LIB}/` passed on POSIX and rejected every + // in-tree import on Windows, where `resolve` returns backslashes. + expect(withinServerLib(join(SERVER_LIB, 'textUtils.js'))).toBe(true); + expect(withinServerLib(join(SERVER_LIB, 'editorial', 'shotContinuity.js'))).toBe(true); + expect(withinServerLib(join(SERVER_LIB, '..', 'services', 'auth.js'))).toBe(false); + expect(withinServerLib(SERVER_LIB)).toBe(false); + expect(specifiersIn("import { readFile } from 'fs';")).toEqual(['fs']); + expect(specifiersIn("export { a } from './b.js';")).toEqual(['./b.js']); + expect(specifiersIn("import './side-effect.js';")).toEqual(['./side-effect.js']); + // A comment naming a module is not an import specifier. + expect(specifiersIn('// loaded from ./b.js when needed')).toEqual([]); + }); + + it('walks every reachable module and finds no Node-only or out-of-tree import', () => { + const violations = impureEdges(entryModules); + expect( + violations.map((v) => `${v.chain.join(' → ')} imports '${v.specifier}' (${v.reason})`), + 'The client imports these server/lib modules, so they must be browser-safe. Split the ' + + 'impure part into its own leaf and import the pure half from both sides — see the ' + + '"One pure module, one definition" rule in client/src/lib/README.md.', + ).toEqual([]); + }); +}); diff --git a/scripts/repo-scan-guards.test.js b/scripts/repo-scan-guards.test.js index cafd7540c8..ddd51dc5ef 100644 --- a/scripts/repo-scan-guards.test.js +++ b/scripts/repo-scan-guards.test.js @@ -66,10 +66,6 @@ const STRUCTURALLY_SELECTED = new Map([ // file" and forces the complete suite. The guard also rides the Windows // contract list. ['scripts/ps1-bom.test.js', 'unclassified-file full-suite trigger: *.ps1'], - // Walks both lib directories and diffs their README mirror catalogs against - // whichever test files exist; it names no file itself (#6363), so the - // basename lookup can't reach it either. - ['server/lib/mirrorCoverage.test.js', 'structuralTestsFor: server/lib/** or client/src/lib/** changed'], ]); /** A `git` invocation… */ @@ -181,7 +177,7 @@ describe('repo-scanning guards are reachable by CI selection (#5055)', () => { }); it('finds the known unnamed cross-root readers', () => { - expect(crossRootReaders).toContain('server/lib/mirrorCoverage.test.js'); + expect(crossRootReaders).toContain('scripts/agent-instructions-files.test.js'); }); it('registers every scanner and unnamed cross-root reader in ALWAYS_RUN_TESTS or names the selector that reaches it', () => { diff --git a/scripts/server-imports-no-client.test.js b/scripts/server-imports-no-client.test.js new file mode 100644 index 0000000000..c71b362551 --- /dev/null +++ b/scripts/server-imports-no-client.test.js @@ -0,0 +1,120 @@ +/** + * Guard: nothing under `server/` imports from `client/`. + * + * The dependency between the two runtimes is one-way — the client imports pure + * `server/lib` leaves (see `client/src/lib/README.md`). The reverse edge is a + * trap in both directions: the server process loads client source at boot, and + * a client-only dependency added to a file the server imports breaks the server + * CI job, which is exactly what PR #3614 hit when a server test reached for + * `client/src/components/cos/constants.js`. + * + * PRODUCTION server code has no such edge and must never gain one. A handful of + * TESTS still import a client copy — each pins a pair that is not a shared pure + * `server/lib` leaf (component constants, service tables), so retiring it means + * moving the module, not deleting an assertion. They are frozen in + * `LEGACY_TEST_CROSS_IMPORTS` below: the list may shrink, never grow. + * + * This scans the tracked tree, so no import edge reaches it — it rides + * `ALWAYS_RUN_TESTS`. + */ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** True when `source` imports (not merely mentions) a module under `client/`. */ +export const importsClientSource = (source) => ( + // The `from` keyword is what separates an import specifier from a path a test + // merely READS (`readFileSync(join(here, '../../client/…'))`) — the remaining + // parity tests do plenty of the latter, and only the former is the hazard. + // Unanchored so a multi-line named-import list counts too. + /\bfrom\s*['"](?:\.\.\/)+client\/[^'"]+['"]/.test(source) + || /(?:^|\n)\s*import\s*['"](?:\.\.\/)+client\/[^'"]+['"]/.test(source) +); + +/** + * Tests that still import a client module. Every entry pins a copy-pair whose + * client side is NOT a re-export of a pure `server/lib` leaf. Shrink this list + * by moving the shared module into `server/lib` and re-exporting it from the + * client — never by adding a row. + */ +const LEGACY_TEST_CROSS_IMPORTS = new Set([ + 'server/cos-runner/allowedCommands.parity.test.js', + 'server/lib/eidoverseWorldReset.parity.test.js', + 'server/lib/goalFeatureMap.test.js', + 'server/lib/icLoraWeights.parity.test.js', + 'server/lib/postPowersLadder.test.js', + 'server/lib/privacyValidation.mirror.test.js', + 'server/lib/renderTargets.parity.test.js', + 'server/lib/spriteAnimationTracks.test.js', + 'server/lib/universeMarkdown.test.js', + 'server/lib/videoContinuity.parity.test.js', + 'server/lib/videoSpeedProfiles.parity.test.js', + 'server/lib/videoTextEncoders.parity.test.js', + 'server/services/imageTo3d/renderOptions.parity.test.js', + 'server/services/imageTo3d/unavailableReasons.parity.test.js', + 'server/services/meatspaceHealth.test.js', + 'server/services/rigging/unavailableReasons.parity.test.js', + 'server/services/rounds.test.js', +]); + +const trackedServerSources = execFileSync( + 'git', + ['ls-files', 'server/*.js', 'server/**/*.js'], + { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, +).split('\n').filter(Boolean); + +const crossImporters = trackedServerSources.filter( + (rel) => importsClientSource(readFileSync(join(REPO_ROOT, rel), 'utf8')), +); + +describe('server/ never imports client/ (#6364)', () => { + it('finds server sources to scan', () => { + // Fails loudly if the glob stops matching, rather than passing vacuously. + expect(trackedServerSources.length).toBeGreaterThan(100); + }); + + it('detects the shape it guards, and leaves prose and same-root imports alone (bypass probe)', () => { + expect(importsClientSource("import { x } from '../../client/src/lib/x.js';")).toBe(true); + expect(importsClientSource("import { x } from '../client/src/lib/x.js';")).toBe(true); + expect(importsClientSource("export { x } from '../../client/src/lib/x.js';")).toBe(true); + expect(importsClientSource("import '../../client/src/lib/x.js';")).toBe(true); + expect(importsClientSource("import { x } from './x.js';")).toBe(false); + // A comment naming a client path is not an import. + expect(importsClientSource('// mirrored to client/src/lib/x.js')).toBe(false); + expect(importsClientSource("readFileSync(join(here, '../../client/src/lib/x.js'), 'utf8');")).toBe(false); + }); + + it('has no production server module importing client source', () => { + const production = crossImporters.filter((rel) => !rel.endsWith('.test.js')); + expect( + production, + 'A server module must not load client source: it puts the client build on the server\'s ' + + 'boot path and lets a client-only dependency break the server CI job. Move the shared ' + + 'module into server/lib and re-export it from client/src/lib instead: ' + + `${production.join(', ')}`, + ).toEqual([]); + }); + + it('adds no test cross-import beyond the frozen legacy list', () => { + const unlisted = crossImporters.filter( + (rel) => rel.endsWith('.test.js') && !LEGACY_TEST_CROSS_IMPORTS.has(rel), + ); + expect( + unlisted, + 'New tests must not import client source — pin the shared logic by moving it into ' + + `server/lib and re-exporting it from the client: ${unlisted.join(', ')}`, + ).toEqual([]); + }); + + it('keeps the legacy list free of entries that no longer cross-import', () => { + const stale = [...LEGACY_TEST_CROSS_IMPORTS].filter((rel) => !crossImporters.includes(rel)); + expect( + stale, + `These no longer import client source — drop the entry so the list keeps shrinking: ${stale.join(', ')}`, + ).toEqual([]); + }); +}); diff --git a/server/lib/README.md b/server/lib/README.md index a93554f8c8..ac091d5f7c 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -106,7 +106,8 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub |---|---| | `editorial/` | Extensible editorial-check registry (#1284) — `EDITORIAL_CHECKS` + fail-fast guards + lookup/state helpers. See `editorial/README.md`. The runner that executes checks lives at `server/services/pipeline/editorial/checkRunner.js`. | | `fableLoomGraph.js` | FableLoom branching-narrative graph analysis: `analyzeEpisodeGraph` (deterministic validation — reachability, dead ends, dangling paths, intent hygiene), `computeGraphLayers` (BFS layering), `describeGraphForPrompt` (compact text rendering for LLM stages), `GRAPH_ISSUE_CODES`. | -| `storyBible.js` | Canonical Character / Place / Object shapes + `BIBLE_LIMITS`. Also the reveal-gated canon / spoiler-scoping helpers (#2178): `filterCanonForIssue` / `filterCanonListForIssue` / `isCanonEntryGatedForIssue` (hide or surface-substitute a gated entry in a drafting prompt), `canonHasRevealGated` + `revealGatedCanonRows` (for the `continuity.premature-reveal` check gate/summary). | +| `storyBible.js` | Canonical Character / Place / Object shapes; re-exports `BIBLE_LIMITS` from `bibleLimits.js`. Also the reveal-gated canon / spoiler-scoping helpers (#2178): `filterCanonForIssue` / `filterCanonListForIssue` / `isCanonEntryGatedForIssue` (hide or surface-substitute a gated entry in a drafting prompt), `canonHasRevealGated` + `revealGatedCanonRows` (for the `continuity.premature-reveal` check gate/summary). | +| `bibleLimits.js` | `BIBLE_LIMITS` — the canon field length/count caps every story-bible sanitizer, Zod schema, and catalog payload upgrade measures against. A pure leaf split out of `storyBible.js` (which pulls `crypto` + `fileUtils`) so `catalogTypes.js` and the browser bundle can read the numbers alone. | | `storyArc.js` | Canonical Arc + Season + Reader-Map shapes for pipeline arc planning. | | `styleGuide.js` | Per-series house style (tense/POV/audience/rating/reading-level/tone/conventions): `sanitizeStyleGuide` + `renderStyleGuide` generation block + enums. | | `storyBuilderSteps.js` | Unified Story Builder ordered step definitions + helpers (`STEPS`, `STEP_IDS`, `STEP_STATUSES`, `isValidStepId`, `stepIndex`). | @@ -124,7 +125,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `seriesLlmOverride.js` | Pure `resolveSeriesLlmOverride(series, { overrideProvider, overrideModel })` → `{ provider, model, providerMatchesSeries }` — shared fallback so Pipeline LLM actions honor the series' configured provider/model, only inheriting the series model when the effective provider still matches. | | `catalogBulkParsers.js` | Dependency-free markdown/CSV/JSON parsers for `POST /api/catalog/bulk-import` and YAML/markdown serializers for `GET /api/catalog/export`. | | `catalogChunking.js` | Pure lossless scrap-text chunker (`chunkRawText`, `CATALOG_CHUNK_MAX_CHARS`) — splits a long paste into ≤maxChars chunks on paragraph/newline/sentence/whitespace boundaries so the catalog extractor processes each child and unions results. | -| `catalogTypes.js` | Shared catalog ingredient TYPE REGISTRY — one entry per type drives validation enum, ID prefix, FTS field set, extraction shape, per-record `payloadSchemaVersion` + upgraders, per-type `defaultTags`. Also exports the relation-kind registry and the tag-taxonomy helpers (`canonicalTagKey`, `tagIdForKey`, `defaultTagsForType`). Mirrored on the client at `client/src/lib/catalogTypes.js`. | +| `catalogTypes.js` | Shared catalog ingredient TYPE REGISTRY — one entry per type drives validation enum, ID prefix, FTS field set, extraction shape, per-record `payloadSchemaVersion` + upgraders, per-type `defaultTags`. Also exports the relation-kind registry and the tag-taxonomy helpers (`canonicalTagKey`, `tagIdForKey`, `defaultTagsForType`). The client projects its UI registry from this one (`client/src/lib/catalogTypes.js`) rather than copying it. | | `catalogUniverseTags.js` | Pure transform that rewrites legacy machine universe tags (`from-universe`, `universe:`) on backfilled catalog ingredients into friendly universe-NAME tags, preserving user tags + the structured `catalog_ingredient_refs` link. Used by the boot-time repair and the bible→catalog backfill. | | `comicScriptParser.js` | Marvel/DC-format comic script parser. | | `composeStyledPrompt.js` | Compose user prompt + negative with an optional style preset. | @@ -285,7 +286,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `pgFileFacade.js` | Shared PG/file store-backend backbone for the six storage dispatchers (pipeline series/issues, story builder, universe builder, catalog user-types, writers room). `isFileBackend()` (dev/test escape-hatch predicate) · `resolvePgBackend({ requirement, migrate?, loadDb, makePg })` (health-check → `ensureSchema` → one-time migration → import `db.js` → build the PG backend) · `createPgFileFacade({ makeFile, makePg })` (promise-memoized lazy selection so concurrent first calls don't run the migration twice; returns `{ getBackend, getBackendName, reset }`). Each store keeps its own `makeFile`/`makePg` factories + public surface. `createRecordStoreBackendSelector({ label, loadFileBackend, loadDbBackend, requireDbMessage?, isTestMode?, onDbReady? })` wraps the same backbone for the stores whose backends are whole MODULES rather than built objects (Creative Director, Music Video, Sprites) → `{ selectBackend, getBackendName }` where the name is `'file'`/`'postgres'`; `isTestMode` lets a store use the stronger `isTestRunner()` signal (Sprites). | | `multipart.js` | Streaming multipart/form-data parser. | | `safetensors.js` | `readSafetensorsHeader(path)` reads only the JSON header of a `.safetensors` file (never the tensor payload). `detectFlux2VariantFromHeader(header)` / `detectFlux2Variant(path)` classify a LoRA as FLUX.2 Klein `'4b'` (hidden dim 3072) vs `'9b'` (4096) by transformer-block tensor shapes, so the LoRA picker can hide off-variant weights that would silently fail to load. `classifyLoraKeyLayoutFromHeader(header)` / `classifyLoraKeyLayout(path)` classify the key layout as `LORA_KEY_LAYOUTS` (`bare` / `comfyui` / `diffusers` / `kohya` / `not_a_lora`, `null` = unreadable), `isKnownLoraKeyLayout(layout)` validates a layout read back out of persisted state, and `videoLoraLayoutIssue(layout)` returns the user-facing reason a layout can't fuse into the LTX-2 video transformer (or `null` when it can). | -| `loraEffect.js` | LoRA adapter-effect report rules — the JS half of the `scripts/lora_effect_probe.py` diagnostic (#4872), mirrored to the client by `client/src/lib/loraEffect.js` and pinned by `loraEffect.parity.test.js` (which also holds `LORA_EFFECT_PROBE_VERSION` in lockstep with the probe`s `PROBE_VERSION` — drift silently disables the cache). `LORA_EFFECT_STATUSES` (`ok`/`zero`/`nonfinite`/`unreadable`/`unmeasurable`) + `isKnownLoraEffectStatus`; `normalizeLoraEffectReport(raw,{sizeBytes,mtimeMs,measuredAt})` coerces every non-finite number to `null`, drops statistics when nothing was measured so "no data" can never read as "measured 0.0", and downgrades a `zero` status that no measurement backs; `readCachedLoraEffectReport(raw,{sizeBytes,mtimeMs})` returns a stored report only while its probe version, file size AND mtime all still match, which is what lets `listLoras()` surface one without ever spawning a probe; `loraEffectIssue(report)` is the user-facing refusal phrase for a measured entirely-zero adapter and `null` for EVERY other status; `formatLoraEffect(report)` is the one-line log/UI summary. | +| `loraEffect.js` | LoRA adapter-effect report rules — the JS half of the `scripts/lora_effect_probe.py` diagnostic (#4872). `client/src/lib/loraEffect.js` re-exports `LORA_EFFECT_STATUSES` from here; `loraEffect.test.js` holds `LORA_EFFECT_PROBE_VERSION` in lockstep with the probe`s `PROBE_VERSION` — drift silently disables the cache. `LORA_EFFECT_STATUSES` (`ok`/`zero`/`nonfinite`/`unreadable`/`unmeasurable`) + `isKnownLoraEffectStatus`; `normalizeLoraEffectReport(raw,{sizeBytes,mtimeMs,measuredAt})` coerces every non-finite number to `null`, drops statistics when nothing was measured so "no data" can never read as "measured 0.0", and downgrades a `zero` status that no measurement backs; `readCachedLoraEffectReport(raw,{sizeBytes,mtimeMs})` returns a stored report only while its probe version, file size AND mtime all still match, which is what lets `listLoras()` surface one without ever spawning a probe; `loraEffectIssue(report)` is the user-facing refusal phrase for a measured entirely-zero adapter and `null` for EVERY other status; `formatLoraEffect(report)` is the one-line log/UI summary. | | `pdfImageEmbed.js` | PDF image embed helpers for comic / volume PDFs. | | `zipStream.js` | Streaming ZIP parser (`parseZip`, unzipper-style); `collectZipEntry(entry, maxBytes?)` buffers one `parseZip` entry into a Buffer (size-capped); `collectZipEntries(path, { match, onMatch, maxBytes? })` owns the multi-entry import lifecycle (teardown, autodrain, per-entry await) leaving callers only match/parse; `isZipUpload(file)` predicate for an uploaded ZIP; `extractZipEntryToBuffer(path, match)` cracks one member out to a Buffer. | | `zipWriter.js` | Minimal ZIP writer — `createZip(entries)` builds a stored (uncompressed) archive Buffer that round-trips through `parseZip`; `crc32(buf)` is the dependency-free checksum it uses. | @@ -318,7 +319,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `gitOutputParsers.js` | `parseStatus`, `parseDiffStat`, `parseBranchVerboseLine`, `parseSubmoduleStatusLine`/`SUBMODULE_STATUS_RE`, `extractAgentSummary` — pure parsers turning git command output into structured data for `git.js`. `extractAgentSummary` anchors on `agentOutputMarkers`' completion marker so a TUI agent's PR body carries its sentinel summary, not the lifecycle telemetry above it. `isBenignConcurrentFetchRefRace(stderr)` reads a non-zero `git fetch` as a SUCCESS when its only failure is a lost compare-and-swap whose refs already hold the fetched commits — the routine outcome when the Git tab, `getRemoteBranches`, and CoS agent worktrees fetch one `.git` at once. | | `gitRemote.js` | `getOriginInfo`, `classifyOriginRemote`, `parseGitRemoteUrl`, `readRemoteUrl`, `UPSTREAM_OWNER`/`UPSTREAM_REPO` — safely reads and classifies checkout remotes against PortOS or a caller-supplied canonical upstream. Used by self-update and managed integrations to detect forks. | | `repoLinkFields.js` | `deriveRepoLinkFields(url)` / `normalizeRepoLinkFields(link)` / `linkIsRepo(link)` / `repoLinkLabel(link)` — the repository metadata a Brain link carries, plus the dual-write/tolerant-read shim that keeps the pre-multi-host `isGitHubRepo` field names readable across federated peers. | -| `repoUrl.js` | `parseRepoUrl(url)` → `{ host, provider, owner, repo }` / `isRepoUrl(url)` / `repoCloneUrl(parsed)` / `repoBrowseUrl(parsed)` / `parseGitHubUrl(url)` / `isGitHubRepoUrl(url)` — authoritative "is this a clonable repo URL?" rule, with per-host behavior (subgroup nesting, clone layout) in the `REPO_HOSTS` table. Mirrored to `client/src/lib/repoUrl.js` (parity pinned by `repoUrl.mirror.test.js`) so the Brain capture boxes reveal the post-clone agent options for exactly the URLs the server will clone. | +| `repoUrl.js` | `parseRepoUrl(url)` → `{ host, provider, owner, repo }` / `isRepoUrl(url)` / `repoCloneUrl(parsed)` / `repoBrowseUrl(parsed)` / `parseGitHubUrl(url)` / `isGitHubRepoUrl(url)` — authoritative "is this a clonable repo URL?" rule, with per-host behavior (subgroup nesting, clone layout) in the `REPO_HOSTS` table. Re-exported by `client/src/lib/repoUrl.js` so the Brain capture boxes reveal the post-clone agent options for exactly the URLs the server will clone. | | `glabArgs.js` | `GLAB_JSON_ARGS`, `withGlabJson(args)` — pure `glab` argv conventions. The JSON output flag is `--output json`, NOT `-F json`: on `glab issue list` (and only there) `-F` is `--output-format` (details/ids/urls), so `-F json` is accepted, ignored, and answers with the human table at exit 0. Shared by all three `glab` runners so the spelling has one definition; guarded tree-wide by `services/gitlab.glabFlags.test.js`. | | `killWithEscalation.js` | `killWithEscalation(proc, {label, stillRunning, delayMs=8000})` — shared SIGTERM→grace→SIGKILL cancel-escalation for spawn-based media jobs. Sends SIGTERM, then escalates to SIGKILL after `delayMs` only when `stillRunning()` holds and the child hasn't exited (`exitCode===null && signalCode===null`). The timer is unref'd and the callback is try/catch-wrapped (runs outside the request lifecycle). Converges musicVideo/render, videoTimeline, imageGen local+codex, videoGen, loraTraining, and the yt-dlp track import cancel paths. | | `npmGlobalBin.js` | `adoptNpmGlobalBinDir()` — puts the directory `npm install --global` actually writes to onto `process.env.PATH`, from one cached `npm prefix -g`. npm resolves its prefix through a config cascade (cli flags, `npm_config_*`, project/user/global npmrc, a builtin npmrc that interpolates env vars), so guessing it from `%APPDATA%`/`$HOME` reproduces the bug on the next host — only npm can answer. Exists because a host whose npm prefix is NOT the directory its Node installer put on PATH (a machine-wide Windows prefix vs the per-user `%APPDATA% @@ -329,7 +330,8 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `pythonSetup.js` | Python venv / runner setup helpers. | | `vttTranscript.js` | WebVTT/SRT → readable prose. `vttToPlainText(vtt)` (paragraph-joined transcript), `vttToLines(vtt)` (cleaned caption lines), `cleanCaptionLine(line)`. Collapses YouTube auto-captions' rolling repetition and strips inline ``/timestamp markup. Used by the brain YouTube ingest. | | `youtubeIngestFormat.js` | Pure data transformations for the brain's YouTube ingest (#6015): `parseVideoMetadata(json)` (yt-dlp `--dump-single-json` → the stored metadata shape, incl. the `subtitles`-vs-`automatic_captions` manual-caption signal), `buildIngestNote({meta,url,transcript,tags,agentPrompt,capturedAt})` (the Obsidian note, whose YAML frontmatter the user's own vault queries key on — every interpolated scalar goes through `yamlString`), `buildAgentTaskContext(...)` (the CoS follow-up prompt, including the untrusted-transcript boundary notice), `resolveObsidianPointer({written,vaultId,notePath,prior})` (keeps the prior note pointer when an ATTEMPTED mirror failed, so an evicted note can't be orphaned — #3706), plus `sanitizeFilename`, `formatDuration` (seconds → `h:mm:ss`) and `yamlString`. No fs/db/childProcess/SSE imports; orchestration and storage stay in `services/youtubeIngest.js`. Surfaced through the barrel as a NAMESPACE export (`youtubeIngestFormat.*`) because `formatDuration` collides with `fileCore.js` and `sanitizeFilename` with `mimeTypes.js`. | -| `youtubeUrl.js` | Canonical YouTube single-video URL rule (#6014). `YOUTUBE_VIDEO_URL_RE` (accepts `watch`/`shorts`/`live`/`embed` plus the `www.`/`m.`/`music.` hosts, rejects playlists, channels, and `/@handle` feeds), `youtubeVideoIdFromUrl(url)` (alias `youtubeVideoId`), `isYoutubeVideoUrl(url)`, `assertYoutubeVideoUrl(url)` (returns the id, else throws 400 `YOUTUBE_URL_INVALID`), and the shared `YOUTUBE_URL_INVALID_MESSAGE`. Single source for the brain ingest, the Takeout importer, the history scrape, and the Music Video track import; mirrored in `client/src/lib/youtubeUrl.js` and pinned by `youtubeUrl.mirror.test.js`. | +| `youtubeUrl.js` | Canonical YouTube single-video URL rule (#6014). `YOUTUBE_VIDEO_URL_RE` (accepts `watch`/`shorts`/`live`/`embed` plus the `www.`/`m.`/`music.` hosts, rejects playlists, channels, and `/@handle` feeds), `youtubeVideoIdFromUrl(url)` (alias `youtubeVideoId`), `isYoutubeVideoUrl(url)`, and the shared `YOUTUBE_URL_INVALID_MESSAGE`. Single source for the brain ingest, the Takeout importer, the history scrape, the Music Video track import, and (by re-export) `client/src/lib/youtubeUrl.js`. A pure leaf — the throwing form lives in `youtubeUrlAssert.js`, so importing this can never drag `errorHandler.js` into the browser bundle. | +| `youtubeUrlAssert.js` | `assertYoutubeVideoUrl(url)` — the throwing form of the `youtubeUrl.js` rule: returns the video id, else throws 400 `YOUTUBE_URL_INVALID`. Separate because it needs `ServerError` (and so Node's `events`), which must stay out of the pure leaf the client re-exports. | | `ytdlp.js` | `findYtDlp()` — cached discovery of the `yt-dlp` binary on PATH, mirrors `findFfmpeg()` in `ffmpeg.js`. Used by the track YouTube-import job. | ## Networking @@ -356,7 +358,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `tailscale.js` | Locate the Tailscale CLI binary, flag the sandboxed macOS App-bundle build, and return a normalized backend/MagicDNS/peer snapshot (`getTailscaleStatus` / `isTailscaleUp`). | | `httpsState.js` | Captures whether PortOS booted with HTTPS active. | | `bareUrl.js` | `parseBareUrl(text)` — returns the normalized URL when a captured string is nothing *but* a URL (bare host gets `https://`), else null. Drives the brain-capture short-circuit that files a pasted URL straight to Links instead of running the classifier. Stricter than the client's `urlNormalize.js` `isUrl` (needs a plausible TLD; http/https/`git@` only) because it picks a storage destination rather than a hint. | -| `isSafeHref.js` | Pure http(s)-only scheme check (`isSafeHref`) for user-supplied URL fields that get rendered as a clickable `` — rejects `javascript:`/`data:`/etc. stored-XSS payloads. Mirror of `client/src/lib/isSafeHref.js`, which `client/src/utils/urlNormalize.js` re-exports as `isHttpUrl`. | +| `isSafeHref.js` | Pure http(s)-only scheme check (`isSafeHref`) for user-supplied URL fields that get rendered as a clickable `` — rejects `javascript:`/`data:`/etc. stored-XSS payloads. `client/src/lib/isSafeHref.js` re-exports it, and `client/src/utils/urlNormalize.js` re-exports that as `isHttpUrl`. | | `networkExposure.js` | Runtime scheme/bind/cert snapshot plus the shared ordered Tailscale, MagicDNS, certificate, and trusted-launch setup guide used by CLI and UI; `localApiBaseUrl()` resolves the plain-HTTP loopback origin (mirror port under HTTPS, API port otherwise) for local scripts and agent-facing curl snippets. | ## Search & indexing @@ -425,12 +427,13 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `mediaItemKey.js` | `:` key vocabulary for media items. | | `assetProvenance.js` | Stamp-time model/LoRA license provenance (`buildProvenance` / `provenanceForRender` / `rollupProvenance`). Unknown stays `null` (displayed as "unknown") — never a permissive default. Mirrored byte-for-byte to `client/src/lib/assetProvenance.js`. | | `avatarVariants.js` | Rigged-record avatar variant spelling (`RIGGED_VARIANT_PREFIX`, `AVATAR_VARIANT_PATTERN`, `parseRiggedVariant`, `riggedVariantForId`, `isAnimatedRecordReady`) — the `rigged-` namespace over `?variant=`, sharing the route's strict traversal guard. | +| `avatarStyles.js` | The CoS avatar-style vocabulary (#6253) — `AVATAR_STYLES` (`{id, label, webgl}`), `AVATAR_STYLE_IDS` (the `avatarStyle` zod enum in `cosStatusRoutes.js`), `AVATAR_STYLE_LABELS`, and `WEBGL_AVATAR_STYLE_IDS` (the three.js-stage set). `client/src/lib/avatarStyles.js` re-exports it for the picker and the lazy-load map. | | `migrationMarker.js` | Shared marker-file helpers for one-time migration/repair/reconcile scripts — `markerExists(filename)` (boolean gate), `readMarker(filename)` (parsed payload or null), `writeMarker(filename, payload)` (atomic write). All anchor `filename` under `PATHS.data` and use `tryReadFile`/`atomicWrite` so a crash can't leave a truncated marker. | | `goalFeatureMap.js` | Deterministic goal `category` → PortOS feature-area map (deep-links sourced from `NAV_COMMANDS`). `getGoalFeatureAreas(goal)` honors the per-goal `featureAreas` override, else the category default. Mirrored byte-for-byte to `client/src/lib/`. | | `goalFidelity.js` | Goal-fidelity review contract (#5994) — the value half of "does this diff deliver what was asked?", the question the quality-review chain structurally cannot answer because it never sees the request. `GOAL_FIDELITY_VERDICTS` (`ship` / `fix-first` / `rethink`, only `rethink` gating a run via `goalFidelityHoldsRun`), `taskObjective(task)` (the trusted operator-authored objective — the TASK's description + prompt block, never the agent's transcript, since a reviewer handed the transcript inherits the assumptions that produced the drift), `resolveGoalFidelityConfig(codeReview, chain)` (enabled/backend/model/effort, restricted to the local-LLM reviewers PortOS can call server-side and falling back to the quality chain's own local reviewer + its `Model`/`Effort` scalars), `normalizeGoalFidelityVerdict(parsed)` (`null` = nothing judged the run, never collapsed into a `ship` pass or a `rethink` hold) and `formatGoalFidelitySummary(review)`. `MAX_OBJECTIVE_CHARS` / `MAX_FIDELITY_DIFF_CHARS` bound what crosses into a fixed-window local model. Pure. | | `navManifest.js` | Single source of truth for nav (`⌘K` palette + voice). Add an entry when you add a page. | | `noReplaceMove.js` | `moveWithoutReplace(from, to)` — publish a staged file into its final name WITHOUT ever clobbering an existing one. `fs.rename` silently replaces its destination, which is the wrong default for a derived artifact; this uses `link(2)` + `unlink(2)`, so an existing destination fails atomically with `MOVE_DEST_EXISTS` and both files survive. Refuses rather than degrading when the filesystem cannot express it (`MOVE_CROSS_DEVICE`, `MOVE_NO_REPLACE_UNSUPPORTED`) — a `stat`-then-`rename` fallback would be a race. Used by the rigging publication contract (`services/rigging/autoSkin.js`). | -| `personaTraitBlend.js` | Digital-twin persona trait-blending (M34 P7). Blends a persona's `traitAdjustments` against the base twin's communication profile + Big-Five into a "Communication Calibration" directive. Mirrored to `client/src/lib/`. | +| `personaTraitBlend.js` | Digital-twin persona trait-blending (M34 P7). Blends a persona's `traitAdjustments` against the base twin's communication profile + Big-Five into a "Communication Calibration" directive. A pure leaf — `client/src/lib/personaTraitBlend.js` re-exports it for the Personas UI preview. | | `textUtils.js` | Pure dependency-free prose helpers. `countWords(text)` is the canonical whitespace-token count (`\S+`); `trimTo(value, max)` trims and bounds strings without coercing non-strings, and is safe for shared modules consumed by the browser; `escapeRegExp(value)` is the one to import instead of re-inlining the escape (a guard in `textUtils.test.js` fails the suite when a copy reappears in any non-test source under `server/`, or in ANY source under `client/src/`, tests and `.jsx` included — the escape half is mirrored on the client at `client/src/lib/textUtils.js`, which the browser imports since it cannot reach `server/lib`); `kebabCase(text)` is the canonical ASCII slug transform (PLAN.md `[slug]` ids and `planner:` labels); `clampToCharLimit(text, max)` is `trimTo`'s reader-facing twin — it cuts on a sentence end (when one sits 60%+ into the allowance) or a word boundary rather than mid-word and returns `{ text, truncated }` so the caller can say the text was cut, which is what bounds an AI-enhanced render prompt to a backend's hard cap (reactor.inc fast-h3 rejects an over-length prompt outright). | | `pipelineIssueOrder.js` | Pure renumber algorithm for pipeline issues. | | `postAdaptive.js` | Pure POST adaptive-difficulty policy — nudges a math drill's primary knob (`steps`/`maxDigits`/`maxExponent`/`tolerancePct`) up/down within clamped bounds from recent scored performance. Opt-in via the config Adaptive toggle. | @@ -467,7 +470,8 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `db/` | Boot DDL for the PostgreSQL schema, split per domain (#2832). `db/schema/index.js` re-exports each module's statement array and composes the two ordered lists `ensureSchemaImpl()` runs on every boot (`buildUpgradeDdl()` then `buildCatalogDdl()`). **Statement order is load-bearing** — append inside the domain module and leave the composer order alone. See `db/schema/README.md` for the module catalog. | | `pgTimestamp.js` | `mirrorTimestamp(value, fallback)` — coerce a hand-editable timestamp into a value Postgres TIMESTAMPTZ always accepts (or fall back), guarding boot-time binds against `Date.parse` rollover + out-of-range years. | | `pgTools.js` | `pg_dump` binary resolution shared by the backup snapshot path and the native↔Docker export path: `resolvePgDumpBinary(serverMajor)` (PORTOS_PGDUMP override → version-aware auto-select → bare `pg_dump`), plus the lower-level `pickPgDump` / `discoverPgDumpCandidates` / `resolvePgDump`. Picks the closest installed `pg_dump` whose major is ≥ the running server's. | -| `ports.js` | Canonical PORTS object (re-exported from `ecosystem.config.cjs`). | +| `ports.js` | Canonical `PORTS` object (mirroring `ecosystem.config.cjs`, pinned by `ports.test.js`), `DEFAULT_PEER_PORT`, and `resolvePostgresPort(pgMode)`. A pure leaf: `client/src/lib/ports.js` re-exports it, so nothing here may read `process.env` at module scope — the env-derived origins live in `portosUrls.js`. | +| `portosUrls.js` | `PORTOS_UI_URL` / `PORTOS_API_URL` — the origins PortOS interpolates into prompts and links, resolved from `PORTOS_*` / `PORT` at module load. Split out of `ports.js` so that module stays free of module-scope `process.env` reads and can be re-exported to the client. | | `platform.js` | Platform/OS detection helpers — listening-port probes plus `isAppleSilicon()` (arm64 darwin; gates MLX model features, detect at the route boundary). | | `signalCrypto.js` | Pure, dependency-free crypto for reading Signal Desktop's encrypted chat DB (#2154): SQLCipher-4 page decryption (`decryptSqlcipherDatabase`, `deriveSqlcipherKeys`, `sqlcipherPageHmac` — PBKDF2-SHA512 HMAC key + AES-256-CBC per page + HMAC-SHA512 verify → plaintext SQLite buffer the built-in `node:sqlite` can open) and Chromium/Electron `safeStorage` unwrap (`decryptSafeStorageValue`, `deriveSafeStorageKey` — macOS AES-128-CBC + PBKDF2-SHA1 over the keychain password). All functions return `{ ok, ... }` reports (never throw) for graceful degradation. Consumed by `services/signalSync.js`. | | `timezone.js` | Timezone utilities for scheduling. `getLocalParts(utcDate, timezone)` / `getUtcOffsetMs(utcDate, timezone)` are the primitives; `nextLocalTime(afterMs, hours, minutes, timezone)` finds the next UTC instant matching a local HH:MM. `anchorLocalMidnightUtc(dayStr, tz)` resolves the first UTC instant belonging to a local date, including zones whose DST transition skips 00:00; `localDayWindowUtc(timezone, atDate?)` exposes an inclusive ISO-string window, while `localDayRangeUtc(dateStr, timezone)` returns a validated half-open `{ start, end }` `Date` pair ending at the next local-date boundary so 23h/25h transition days remain exact. Also owns `HHMM_RE`/`HHMM_STRICT_RE`, `parseHHMM`, and `isWithinTimeWindow` (mirrored client-side in `client/src/utils/timeWindow.js`). | @@ -497,7 +501,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `eidoverseWorldDesign.js` | Immutable Eidoverse World Design V1/V2/V3 registry, override migration, semantic districts, 48-signal ceiling, and install-local asset locks for PortOS Commons. | | `eidoverseWorldLabels.js` | The `comp.label` component PortOS attaches to every entity it projects into Eidoverse (eidoverse-worlds#5), so a rendered building says what it represents instead of only naming its decorative model. `buildEidoverseLabel(component, alias?)` reads one already-built `comp.portos` payload and returns `{name, description?, visibility, offset?}` — district and world-identity landmarks always visible, live indicators labelled `nearby`, path markers `inspect`-only so a walkway cannot bury the district it leads to — and `null` for anything not `managedBy: 'portos'`, so a caller cannot label somebody else's entity. Names and descriptions are built from PortOS's own vocabulary (district label, kind label, resource category, coarse status, freshness) plus an opaque hashed resource ref for disambiguation, never from record contents, machine identity, addresses, or filesystem paths — the append-only world log is the strictest privacy boundary PortOS writes to. Also owns bounded opaque-key alias normalization and V1 renderer capability parsing; aliases are explicit opt-in names and are never inferred from private records. Owns `safeWorldText`, the one control-character/length sanitizer shared with `services/eidoverseWorldProjection.js` so a component field and the label built from it cannot disagree about what is safe. Pure. | | `errorHandler.js` | `ServerError` + `asyncHandler` middleware, plus `sendErrorResponse`/`buildErrorEnvelope` for the standard `{ error, code, timestamp }` body outside a handler's catch. | -| `extensionErrors.js` | `isExtensionError(payload)` — true when a client error report came from a browser extension's injected content script (extension URL scheme in `source`/`stack`/`message`, or a short list of vendor/runtime message signatures) rather than from PortOS. Consumed by `services/clientErrors.js` to keep un-actionable extension noise out of the Review Hub *and* out of the 1/sec throttle slot, where it would displace real errors. **Authoritative copy** — mirrored at `client/src/lib/extensionErrors.js`, parity enforced by `extensionErrors.mirror.test.js`. | +| `extensionErrors.js` | `isExtensionError(payload)` — true when a client error report came from a browser extension's injected content script (extension URL scheme in `source`/`stack`/`message`, or a short list of vendor/runtime message signatures) rather than from PortOS. Consumed by `services/clientErrors.js` to keep un-actionable extension noise out of the Review Hub *and* out of the 1/sec throttle slot, where it would displace real errors. Re-exported by `client/src/lib/extensionErrors.js`, so both sides classify identically. | | `fetchErrorChain.js` | `describeFetchError(err)` flattens a fetch rejection's whole `cause` chain (depth-bounded, cycle-guarded) into one searchable `code: message` string — undici reports every network failure as the same opaque `TypeError: fetch failed` with the real reason nested inside, so a classifier reading only `err.message` misjudges every one of them. `isReplayableConnectionError(err)` is the narrow predicate over that string for connection-REUSE artifacts (HTTP/2 GOAWAY, reset, hang-up) that are safe to replay once via `fetchWithTimeout`'s `shouldRetry`; it deliberately EXCLUDES timeouts, which broader classifiers like `ollamaManager`'s `isTransientPullError` include. | | `isoWeek.js` | ISO-8601 week identity — the single source of the `YYYY-Www` week id. `getWeekId(date)` keys on the ISO week-numbering **year** (the calendar year of that week's Thursday), not `date.getFullYear()`, so one ISO week is never split across two ids and two weeks never collide on one (#3465). Also `isoWeekParts`, `getIsoWeekNumber`, `getIsoWeekYear`, `parseWeekId` (null on garbage), and `isoWeeksInYear(year)` (52, or 53 in a leap-week year). Shared by productivity week aggregates and weekly digest filenames. | | `snapshotChecksum.js` | The two snapshot-checksum flavours a sync category can want. `snapshotChecksum(data)` hashes `JSON.stringify` — insertion-order SENSITIVE, correct only where the getter already canonicalizes its own ordering (`dataSync.js`, `digital-twin-sync.js`). `canonicalSnapshotChecksum(data)` hashes `canonicalStringify`, so two converged machines hash identically regardless of the order they learned the data (`peerUsage.js`, whose payload is a map keyed by wire-supplied instance ids). Picking the wrong one is not a crash — it is two synced peers whose checksums never match, which the sync UI reads as "behind" forever. | @@ -523,7 +527,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `shellQuote.js` | `shellQuote(value)` — POSIX single-quote escaping for values interpolated into shell command strings (display command lines, copy-paste blocks in agent prompts). Bare-safe tokens pass through; everything else is single-quoted. Canonical escaper — don't hand-roll. | | `shellReadinessProbe.js` | `buildReadinessProbe(nonce, shell)` — the round-trip "can this shell actually run commands yet?" probe `createShellSession`'s `waitForPromptReady` sends and watches for, in the dialect the session speaks: POSIX `printf '%s\n' 'PORTOSRDY'''` (unchanged), PowerShell `Write-Output ('PORTOSRDY' + '')`, `cmd.exe` → `null` (no split-literal concatenation operator exists there, so the caller skips straight to the bounded fallback timer instead of risking an always-matching probe). The split-literal property is load-bearing in every dialect: the probe source never contains the assembled marker, so seeing it in the output can only mean the shell executed the probe. | | `sidecarProcess.js` | `runSidecarProcess({bin,args,env,signal,onStage,onProcess})` + `parseSidecarResult(stdout)` — the shared Python-sidecar STAGE:/RESULT: wire-protocol runner (spawn, tail-capped stdout/stderr, per-STAGE-line callback, abort/SIGTERM → `canceled`, non-zero exit → stderr-tail reason). Used by `pipeline/musicGen.js` (all music backends) and `audioMidiTranscription.js` (MuScriptor). | -| `slashdoCatalog.js` | The single catalog of which bundled slashdo workflows PortOS offers as a one-click agent run — `SLASHDO_WORKFLOWS` (`{command, label, description, icon, templateName, templatePrompt?, settings, appTypes, configurable?}`), `getSlashdoWorkflow()` (the allowlist gate for `POST /api/cos/tasks/slashdo`), `SLASHDO_COMMAND_NAMES`, `slashdoWorkflowsForApp(isSwiftApp)`, `SLASHDO_APP_TYPES`, and the two run-shape postures `WORKFLOW_OWNS_ITS_OWN_GIT` (commit-shaped) / `WORKFLOW_REPORTS_NO_CODE` (report-shaped — carries `worktreeChangesExpected: false` so a filed-issue/printed-report run retains its non-code deliverable posture). Backs the Agent Operations buttons, the CoS quick templates (`taskTemplates.js`), and the route allowlist, replacing two catalogs that had drifted. Mirrored in `client/src/lib/slashdoCatalog.js` (button styling only), pinned by `slashdoCatalog.test.js`. | +| `slashdoCatalog.js` | The single catalog of which bundled slashdo workflows PortOS offers as a one-click agent run — `SLASHDO_WORKFLOWS` (`{command, label, description, icon, templateName, templatePrompt?, settings, appTypes, configurable?}`), `getSlashdoWorkflow()` (the allowlist gate for `POST /api/cos/tasks/slashdo`), `SLASHDO_COMMAND_NAMES`, `slashdoWorkflowsForApp(isSwiftApp)`, `SLASHDO_APP_TYPES`, and the two run-shape postures `WORKFLOW_OWNS_ITS_OWN_GIT` (commit-shaped) / `WORKFLOW_REPORTS_NO_CODE` (report-shaped — carries `worktreeChangesExpected: false` so a filed-issue/printed-report run retains its non-code deliverable posture). Backs the Agent Operations buttons, the CoS quick templates (`taskTemplates.js`), and the route allowlist, replacing two catalogs that had drifted. `client/src/lib/slashdoCatalog.js` decorates this list with button styling rather than restating it. | | `slashdoInvocation.js` | Resolves how a bundled slashdo workflow is invoked on a given host CLI. `resolveSlashdoInvocation({command, args, providerId, providerCommand, leanMode})` → `{command, args, style, invocation, skillName}` for the three shapes slashdo's installer produces (`slash-namespaced` `/do:x` for Claude Code, `slash-flat` `/do-x` for OpenCode, `skill` — an Agent Skill selected by name — for codex/grok/antigravity and any unidentified provider). Plus `buildSlashdoSection(resolved, body, {bodyPath, reviewWith})` (pure renderer; the caller loads the body via `loadSlashdoFile`. With a staged `bodyPath` it emits a filesystem pointer for deferred bundles or bodies over `SLASHDO_INLINE_BUDGET_CHARS` (24,000)), `unreachableReviewerIncludes({reviewers, usernames})` → the reviewer-variant lib includes a run can never reach (the `skipIncludes` set for `loadSlashdoFile`; defaults to pruning NOTHING for an unresolved/unrecognized reviewer set, since an over-pruned prompt is worse than a fat one), `SLASHDO_REVIEWER_INCLUDES`/`SLASHDO_REVIEWER_INCLUDE_NAMES`, `slashdoSkillName`, `isValidSlashdoCommand` (the one definition of the safe bare-command shape, also gating the Zod schemas), `agentOwnsPrWorkflow({providerType, leanMode})` — a strictly WEAKER question than typing a slash command: any local `cli`/`tui` harness that is not a lean `--bare` session drives its own commit → push → PR → review → merge (#3733), with `resolveOwnsPrWorkflow({persisted, …})` reading the value stamped on a completed agent record and falling back to the slash-command gate for pre-#3733 records; `oversizedBodyPointer(bodyPath, body)` (the shared "it is on disk, go read it" line); and `canTypeSlashCommands()` — the single predicate behind `agentPromptBuilder.js`'s completion-workflow gates (can this session TYPE `/do:pr` / `/simplify`?), which reads `resolveSlashdoStyle`'s `assumeClaudeWhenUnknown` posture because the spawners resolve a blank command to `claude`. Provider is only known at spawn time, so a task persists the BARE command name — never a rendered `/do:x` string. | | `slashdoLoader.js` | Shared slashdo renderer adapter: `loadSlashdoBundle(cmd, {stripFrontmatter, skipIncludes, defer})` returns an entrypoint and supporting files using the bundled transformer; `loadSlashdoFile` returns a self-contained command; `loadSlashdoLib` expands explicit reads for legacy recipes without traversing see-also links. `writeResolvedSlashdoBody(cmd, body, {files})` stages immutable content-addressed bundles under `PATHS.slashdoResolved`, with relative library paths and the entrypoint written last. Missing required references fail dispatch rather than silently removing workflow gates. | | `singleFlight.js` | `createSingleFlight()` → `run(key, fn)` — keyed in-flight coalescer: concurrent calls for the same key share one `fn()` execution and result; the slot auto-clears on settle. Minimal by design (no TTL/result cache layered on top, doesn't reject concurrent callers). Used by `services/promptRunner.js`'s fallback mark-and-pick. | @@ -550,7 +554,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and |---|---| | `dbTestGate.js` | `requireDbOrSkip(label, dbReady, reason)` keeps a missing local test database as a visible skipped suite, but throws when `PORTOS_REQUIRE_DB` is set so CI cannot pass after DB-backed suites disappear. | | `gitTestRepo.js` | Shared real-git sandbox for integration tests (#4394): one initialized template (working tree + bare origin) per worker, then `fs.cp` into a fresh temp dir. `makeGitSandbox({ origin })`, `attachBareOrigin(scratch, repo)`, `materializeGitRepo(dest)`, `destroyGitSandbox`, plus `SKIP_HEAVY_INTEGRATION` (`VITEST_FAST=1`). `resetGitSandbox({ scratch, repo, initialHead })` / `resetGitWorktreeSandbox(repo, initialHead)` restore a sandbox in place (branches, worktrees, remote) so a `describe` can build one sandbox in `beforeAll` and reset between tests instead of paying the fs.cp/rm cycle per test (#5902). Every entry point runs `assertTempPath` first, so a path outside `os.tmpdir()` throws instead of `git init`-ing or `rm -rf`-ing a real checkout (#4554). Still real git — just not rebuilt from `init`+`commit`+`push` in every `beforeEach`. | -| `mirrorParity.js` | Source-comparison primitives for the `*.mirror.test.js` server↔client parity tests: `stripCommentsAndNormalize` (so per-side commentary may diverge but logic may not), `extractDeclaration(src, name)` (balanced `{}`/`()`/`[]` walk over `function` / `async function` / `const`), `compareDeclaration(serverSrc, clientSrc, name)`, and `compareRegexDeclaration(serverSrc, clientSrc, serverName, clientName?)` / `regexAlternationSource(declText)` (for a regex spelled as a `new RegExp([…].join('|'), 'i')` array on one side and an inline `/…/i` literal on the other — compares what it matches, not how it is typeset, and returns `null` rather than a partial read on any shape it can't decode). Use these instead of hand-rolling a brace-walker per mirror. Pure — no `vitest` import — so callers own the assertions. | +| `mirrorParity.js` | Source-comparison primitives for the remaining `*.mirror.test.js` parity tests — the copies that are NOT pure `server/lib` leaves (component constants, service tables), since a shared pure module is imported rather than mirrored: `stripCommentsAndNormalize` (so per-side commentary may diverge but logic may not), `extractDeclaration(src, name)` (balanced `{}`/`()`/`[]` walk over `function` / `async function` / `const`), `compareDeclaration(serverSrc, clientSrc, name)`, and `compareRegexDeclaration(serverSrc, clientSrc, serverName, clientName?)` / `regexAlternationSource(declText)` (for a regex spelled as a `new RegExp([…].join('|'), 'i')` array on one side and an inline `/…/i` literal on the other — compares what it matches, not how it is typeset, and returns `null` rather than a partial read on any shape it can't decode). Use these instead of hand-rolling a brace-walker per mirror. Pure — no `vitest` import — so callers own the assertions. | | `mockPathsDataRoot.js` | Shared Vitest helpers for `PATHS.data → temp dir` and no-peer record creation guards. | | `settingsTestUtil.js` | `bindSettingsFile(dataRoot)` → `writeSettingsFile`/`mergeSettingsFile`: direct settings.json disk writes that also drop the `getSettings()` read cache (dynamic-import reset) so a stale cache can't survive a bypass-`save()` write. | | `runtimeEnv.js` | `isTestRunner()` — NODE_ENV=test **or** the VITEST env var, so a run that dropped NODE_ENV is still armed. Dependency-free and deliberately apart from `db.js`, which used to own it: the lowest-level file primitives need the same answer and must not pull in `pg`, and the many suites spelling `vi.mock('../lib/db.js', () => ({ query }))` used to strip it out of the graph for every other consumer. | diff --git a/server/lib/appIdentity.mirror.test.js b/server/lib/appIdentity.mirror.test.js deleted file mode 100644 index 53af99262a..0000000000 --- a/server/lib/appIdentity.mirror.test.js +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PORTOS_APP_ID as serverAppId } from './appIdentity.js'; -import { PORTOS_APP_ID as clientAppId } from '../../client/src/lib/appIdentity.js'; - -describe('appIdentity — server/client mirror parity', () => { - it('keeps the baseline app id identical', () => { - expect(clientAppId).toBe(serverAppId); - }); -}); diff --git a/server/lib/assetProvenance.test.js b/server/lib/assetProvenance.test.js index ac3beed894..0a78b5550c 100644 --- a/server/lib/assetProvenance.test.js +++ b/server/lib/assetProvenance.test.js @@ -1,7 +1,4 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { UNKNOWN_LICENSE_LABEL, buildProvenance, @@ -178,12 +175,3 @@ describe('buildProvenanceSource', () => { expect(buildProvenanceSource({ kind: 'model', id: '' })).toBeNull(); }); }); - -describe('client mirror', () => { - it('stays byte-for-byte with client/src/lib/assetProvenance.js', () => { - const here = dirname(fileURLToPath(import.meta.url)); - const server = readFileSync(join(here, 'assetProvenance.js'), 'utf8'); - const client = readFileSync(join(here, '../../client/src/lib/assetProvenance.js'), 'utf8'); - expect(client).toBe(server); - }); -}); diff --git a/server/lib/avatarStyles.js b/server/lib/avatarStyles.js new file mode 100644 index 0000000000..d920018313 --- /dev/null +++ b/server/lib/avatarStyles.js @@ -0,0 +1,41 @@ +/** + * Single source of truth for the CoS avatar-style vocabulary. Every consumer + * that used to hand-maintain its own list derives from `AVATAR_STYLES` + * instead (#6253): the picker labels (`components/cos/constants.js`), the + * lazy-load map and WebGL-stage set (`pages/ChiefOfStaff.jsx`), and the + * server's `avatarStyle` zod enum (`server/routes/cosStatusRoutes.js`, + * which imports it directly). + * + * `webgl: true` marks a style that needs the three.js canvas stage — + * `CANVAS_AVATAR_STYLES` derives from this flag. The 2D `core` canvas style + * and the inline `svg`/`ascii` styles are deliberately `webgl: false`. + * + * Lives server-side because the dependency direction is one-way: the client + * imports pure `server/lib` leaves, never the reverse. `client/src/lib/avatarStyles.js` + * re-exports this module for the UI. + */ + +export const AVATAR_STYLES = [ + { id: 'svg', label: 'Digital (SVG)', webgl: false }, + { id: 'cyber', label: 'Cyberpunk (3D)', webgl: true }, + { id: 'sigil', label: 'Arcane Sigil (3D)', webgl: true }, + { id: 'esoteric', label: 'Esoteric (3D)', webgl: true }, + { id: 'nexus', label: 'Neural Nexus (3D)', webgl: true }, + { id: 'muse', label: 'Cyber Muse (3D)', webgl: true }, + // Kestrel Neon's rotating wireframe icosahedron — 2D canvas, no WebGL needed. + { id: 'core', label: 'Core Assembly (Canvas)', webgl: false }, + // Bundled CC0 Kenney Mini Characters — animated rigged GLB avatars. + { id: 'miniMaleC', label: 'Mini Character — Male (3D)', webgl: true }, + { id: 'miniFemaleD', label: 'Mini Character — Female (3D)', webgl: true }, + { id: 'ascii', label: 'Minimalist (ASCII)', webgl: false }, +]; + +export const AVATAR_STYLE_IDS = AVATAR_STYLES.map((style) => style.id); + +export const AVATAR_STYLE_LABELS = Object.fromEntries( + AVATAR_STYLES.map((style) => [style.id, style.label]) +); + +export const WEBGL_AVATAR_STYLE_IDS = new Set( + AVATAR_STYLES.filter((style) => style.webgl).map((style) => style.id) +); diff --git a/server/lib/bareUrl.js b/server/lib/bareUrl.js index 2f44d0db0e..046025a119 100644 --- a/server/lib/bareUrl.js +++ b/server/lib/bareUrl.js @@ -12,9 +12,9 @@ * URL. A bare host therefore needs a plausible TLD, and only http/https/git@ are * accepted (no `javascript:`/`data:`/`file:`). * - * AUTHORITATIVE COPY — mirrored to `client/src/lib/bareUrl.js` so the capture - * boxes can preview this exact decision. Parity is enforced by - * `bareUrl.mirror.test.js`; port any change to both. + * A pure leaf: `client/src/lib/bareUrl.js` re-exports it so the capture boxes + * preview this exact decision. Import no Node built-in here, and nothing outside + * `server/lib`. */ // Explicit http(s) scheme — the URL constructor does the real validation below. diff --git a/server/lib/bareUrl.mirror.test.js b/server/lib/bareUrl.mirror.test.js deleted file mode 100644 index 67616f50d9..0000000000 --- a/server/lib/bareUrl.mirror.test.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Mirror parity test for server/lib/bareUrl.js ↔ client/src/lib/bareUrl.js - * - * The server decides where a capture is filed; the client only previews that - * decision ("that's a URL — it will be saved to Links", and the Creative toggle - * it disables). A drifted client promises a filing the server won't perform — - * exactly the lie this mirror exists to prevent. - * - * Comparison strips comments, so the intentionally divergent header commentary - * does not fail the test — only logic does. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { compareDeclaration } from './mirrorParity.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const SERVER_PATH = resolve(__dirname, 'bareUrl.js'); -const CLIENT_PATH = resolve(__dirname, '../../client/src/lib/bareUrl.js'); - -const MIRRORED_NAMES = [ - 'HTTP_SCHEME_PATTERN', - 'SSH_GIT_PATTERN', - 'DOMAIN_LIKE_PATTERN', - 'FILE_EXTENSION_TAIL', - 'looksLikeFilename', - 'parseBareUrl', -]; - -describe('bareUrl server↔client mirror parity', () => { - const serverSrc = readFileSync(SERVER_PATH, 'utf8'); - const clientSrc = readFileSync(CLIENT_PATH, 'utf8'); - - it('both files are non-empty', () => { - expect(serverSrc.length).toBeGreaterThan(100); - expect(clientSrc.length).toBeGreaterThan(100); - }); - - for (const name of MIRRORED_NAMES) { - it(`${name} is present and identical on both sides (code only)`, () => { - const { serverDecl, clientDecl, serverNorm, clientNorm } = - compareDeclaration(serverSrc, clientSrc, name); - - expect(serverDecl, `server/lib/bareUrl.js is missing: ${name}`).not.toBeNull(); - expect(clientDecl, `client/src/lib/bareUrl.js is missing: ${name}`).not.toBeNull(); - expect( - clientNorm, - `"${name}" diverged — the server copy is authoritative; port the change verbatim`, - ).toBe(serverNorm); - }); - } -}); diff --git a/server/lib/bibleLimits.js b/server/lib/bibleLimits.js new file mode 100644 index 0000000000..4810dfbf93 --- /dev/null +++ b/server/lib/bibleLimits.js @@ -0,0 +1,172 @@ +/** + * Character/place/object canon field caps — the ONE table of length and count + * limits every story-bible sanitizer, Zod schema, and catalog payload upgrade + * measures against. + * + * A pure leaf on purpose: `server/lib/storyBible.js` (which owns the + * sanitizers) pulls `crypto` and `fileUtils`, and `server/lib/catalogTypes.js` + * plus the browser bundle need only the numbers. Keeping the table here lets + * both import it without dragging Node built-ins into the client build — + * `client/src/lib/bibleLimits.js` re-exports it. Import no Node built-in here. + */ + +export const BIBLE_LIMITS = Object.freeze({ + NAME_MAX: 200, + ROLE_MAX: 200, + ALIAS_MAX: 100, + ALIASES_PER_ENTRY_MAX: 12, + PHYSICAL_DESCRIPTION_MAX: 2000, + PERSONALITY_MAX: 2000, + BACKGROUND_MAX: 2000, + NOTES_MAX: 4000, + IMAGE_REF_MAX: 500, + IMAGE_REFS_PER_ENTRY_MAX: 12, + // Extended character identity (novelist + graphic-novelist needs). All + // optional; sanitizer trims missing/blank to empty string. These flow into + // the bible-extraction prompt + the universe-character-expand LLM call. + PRONOUNS_MAX: 60, + AGE_MAX: 80, + CORE_THEME_MAX: 500, + SPEECH_ACCENT_MAX: 500, + // Written speech-pattern: cadence, sentence-structure, lexical tics, vocal + // habits — *not* the regional accent (that lives in SPEECH_ACCENT_MAX). + // Roomier than accent because writers tend to describe rhythm + vocabulary + // + idiom in one paragraph. + SPEECH_PATTERN_MAX: 1000, + VISUAL_NOTES_MAX: 1000, + SILHOUETTE_NOTES_MAX: 2000, + POSTURE_NOTES_MAX: 1000, + SPECIAL_TRAITS_MAX: 2000, + VISUAL_IDENTITY_MAX: 1000, + MOTIVATIONS_MAX: 2000, + // Character framework (CWQE Phase 10, #2175). The Ghost → Wound → Lie → + // Want → Need chain + Three Sliders + declared arc type. All OPTIONAL so + // every pre-existing character round-trips unchanged (absent vs empty rule). + // The checkable-test discipline (state the Lie in one sentence; Truth is its + // direct opposite; Ghost causally explains the Lie) lives in the prompt, not + // the sanitizer — these caps just bound each field's length. + GHOST_MAX: 1000, + WOUND_MAX: 1000, + LIE_MAX: 600, + WANT_MAX: 600, + NEED_MAX: 600, + // Secrets the character keeps (≥2 encouraged in the prompt). Short prose + // items, capped per-item and per-character like other string lists. + SECRET_MAX: 600, + SECRETS_PER_CHARACTER_MAX: 12, + // Three Sliders — proactivity / likability / competence on a 1–10 scale. + // Stored as integers; a value outside the range (or a non-integer) collapses + // to null (unset). Rule (prompt-enforced, not sanitizer-enforced): HIGH on ≥2, + // or HIGH on one with clear growth; all-low = boring, all-high = Mary Sue. + SLIDER_MIN: 1, + SLIDER_MAX: 10, + LIKES_MAX: 1500, + DISLIKES_MAX: 1500, + MANNERISMS_MAX: 1500, + RELATIONSHIPS_MAX: 2000, + // Structured character-to-character relationship links (#1287). The legacy + // prose `relationships` field above stays; `relationshipLinks[]` is additive. + // `description` is per-link prose; `opposition` captures a binary-tension + // axis (hunter/prey, winner/loser…) the reader watches to see reverse. + RELATIONSHIP_TARGET_ID_MAX: 64, + RELATIONSHIP_TYPE_MAX: 60, + RELATIONSHIP_DESCRIPTION_MAX: 1000, + RELATIONSHIP_OPPOSITION_AXIS_MAX: 60, + RELATIONSHIP_OPPOSITION_ROLE_MAX: 120, + RELATIONSHIP_OPPOSITION_NOTE_MAX: 600, + RELATIONSHIP_LINKS_PER_CHARACTER_MAX: 40, + SKILLS_MAX: 2000, + // Flexible stats list — open key/value so non-humans aren't forced into + // human anatomy ("Number of eyes: 8", "Form: spectral vapor", etc). + STAT_LABEL_MAX: 80, + STAT_VALUE_MAX: 200, + STATS_PER_CHARACTER_MAX: 30, + // Color palette: named hex swatches with role hints ("amber #f59e0b — skin"). + COLOR_NAME_MAX: 80, + COLOR_HEX_MAX: 10, + COLOR_ROLE_MAX: 120, + COLORS_PER_PALETTE_MAX: 12, + // Props (graphic-novelist reference): per-prop name + purpose + materials. + PROP_NAME_MAX: 120, + PROP_PURPOSE_MAX: 400, + PROP_MATERIALS_MAX: 200, + PROP_NOTES_MAX: 600, + PROPS_PER_CHARACTER_MAX: 12, + // Expressions + hand gestures: named visual cues for reference-sheet panels. + EXPRESSION_NAME_MAX: 80, + EXPRESSION_DESC_MAX: 400, + EXPRESSIONS_PER_CHARACTER_MAX: 16, + GESTURE_NAME_MAX: 80, + GESTURE_DESC_MAX: 300, + GESTURES_PER_CHARACTER_MAX: 12, + // Wardrobes per character — A2 in the AnyFilm gap analysis. Each entry + // is an outfit/styling variant; first one is the visual default. + WARDROBE_NAME_MAX: 120, + WARDROBE_DESCRIPTION_MAX: 800, + WARDROBES_PER_CHARACTER_MAX: 10, + EVIDENCE_ITEM_MAX: 500, + EVIDENCE_PER_ENTRY_MAX: 20, + // Places + SLUGLINE_MAX: 200, + PALETTE_MAX: 200, + ERA_MAX: 200, + WEATHER_MAX: 200, + RECURRING_DETAILS_MAX: 1000, + PLACE_DESCRIPTION_MAX: 2000, + // Objects + OBJECT_DESCRIPTION_MAX: 2000, + SIGNIFICANCE_MAX: 1000, + // Structured object↔character attachment links (#1288). The legacy prose + // `significance` field above stays; `attachments[]` is additive. Each link + // ties an object to ONE character and captures the emotion/significance/origin + // of that bond plus a `role` archetype. `characterId` caps match the canon id + // format; the prose fields are roomy because writers describe backstory at + // length, but tighter than NOTES so a runaway extraction stays bounded. + ATTACHMENT_CHARACTER_ID_MAX: 64, + ATTACHMENT_EMOTION_MAX: 120, + ATTACHMENT_SIGNIFICANCE_MAX: 1000, + ATTACHMENT_ORIGIN_MAX: 1000, + ATTACHMENTS_PER_OBJECT_MAX: 40, + // Per-bible cap (universal — protects against runaway extraction) + ENTRIES_PER_BIBLE_MAX: 200, + PROMPT_MAX: 2000, + TAG_MAX: 60, + TAGS_PER_ENTRY_MAX: 12, + SOURCE_SERIES_ID_MAX: 64, + // Catalog backlink: when an embedded bible entry is promoted to the + // creative-ingredients catalog (server/services/catalogDB.js), this carries + // the catalog row id so edits stay synchronized. Cap matches the catalog's + // own id format ('cat--') — generous so future id schemes fit. + INGREDIENT_ID_MAX: 64, + // Voice id namespace: `engine:voiceName` (e.g. `kokoro:af_heart`, + // `piper:en_GB-northern_english_male`). Caps generously since 3rd-party + // providers (ElevenLabs) use uuid-shaped voice ids. + VOICE_ID_MAX: 200, + // Versioned, portable voice-production intent (#5378). This records only + // creative direction and an approval decision; local profiles, providers, + // recordings, and training artifacts deliberately have no slot here. + VOICE_CANON_VERSION_MAX: 100000, + VOICE_CANON_DESCRIPTION_MAX: 1200, + VOICE_CANON_DELIVERY_MAX: 1200, + VOICE_CANON_RANGE_ITEM_MAX: 240, + VOICE_CANON_RANGE_MAX: 12, + VOICE_CANON_AVOID_ITEM_MAX: 240, + VOICE_CANON_AVOID_MAX: 12, + VOICE_CANON_PRONUNCIATION_TERM_MAX: 160, + VOICE_CANON_PRONUNCIATION_VALUE_MAX: 240, + VOICE_CANON_PRONUNCIATIONS_MAX: 24, + // Approved identity-pack assets are a curated view over imageRefs[], not a + // second image store. Only an existing managed reference can be assigned. + IDENTITY_PACK_ASSETS_MAX: 24, + IDENTITY_PACK_AVOID_ITEM_MAX: 240, + IDENTITY_PACK_AVOID_MAX: 12, + // Reveal-gated canon (#2178): `surfaceDescriptor` is the pre-reveal + // stand-in — what the world looks like BEFORE the spoiler is due ("the + // locked east wing" vs "the wing where the heir is imprisoned"). Roomy + // like a place description so a full surface-level paragraph fits. + SURFACE_DESCRIPTOR_MAX: 2000, + // Upper bound for the issue number a canon fact is revealed in. A generous + // cap that comfortably exceeds any real series length while still rejecting + // a hallucinated/overflowed integer. + REVEAL_ISSUE_MAX: 100000, +}); diff --git a/server/lib/canonPrompt.js b/server/lib/canonPrompt.js index 4d0c9eed86..8d61a4601f 100644 --- a/server/lib/canonPrompt.js +++ b/server/lib/canonPrompt.js @@ -1,7 +1,7 @@ /** * Shared per-kind field-precedence rules for canon entries. Pure ESM, no - * Node-only deps — mirrored to `client/src/lib/canonPrompt.js` for the - * client bundle. + * Node-only deps — `client/src/lib/canonPrompt.js` re-exports it for the client + * bundle, so nothing here may import outside `server/lib`. * * Source of truth for "which fields describe a canon entry of this kind, * in what order". Consumers: @@ -261,8 +261,8 @@ export function hasCanonDescriptorContent(kind, entry) { // builder and future per-page render prompts so the join logic stays in one // place. Each returns `''` when the input is missing/empty. // -// Server-only — NOT part of the `client/src/lib/canonPrompt.js` mirror -// contract. Adding them client-side would bloat the bundle for code that +// Server-only: `client/src/lib/canonPrompt.js` re-exports this module by NAME, +// and deliberately leaves these out — they would bloat the bundle for code that // only runs in image-gen / prompt-building paths. export function flattenStats(stats) { if (!Array.isArray(stats) || stats.length === 0) return ''; diff --git a/server/lib/canonPrompt.mirror.test.js b/server/lib/canonPrompt.mirror.test.js deleted file mode 100644 index f28110cba0..0000000000 --- a/server/lib/canonPrompt.mirror.test.js +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Mirror parity test for server/lib/canonPrompt.js ↔ client/src/lib/canonPrompt.js - * - * The mirrored portion includes: - * - SHORT_SPEC, PREVIEW_SPEC, RICH_SPEC constant bodies - * - normalizeKind, fragmentsFromSequence, sequenceHasAnyField function bodies - * - shortCanonDescriptorFragments, richCanonDescriptorFragments, - * mapCanonDescriptorFragments, flattenCanonDescriptorFragments, - * descriptorForCanonEntry, previewCanonFragments, hasCanonDescriptorContent - * function bodies - * - * The server-only section (flattenStats, flattenPalette, flattenWardrobes, - * flattenProps, flattenNamedList, etc.) is NOT checked — those are explicitly - * excluded from the mirror contract by the "Server-only" comment. - * - * Comparison strategy: strip single-line and multi-line comments, then - * normalize whitespace before diffing (see lib/mirrorParity.js). This means - * JSDoc divergence between the two sides does NOT fail the test — only code - * logic differences do. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { compareDeclaration, extractDeclaration } from './mirrorParity.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const SERVER_PATH = resolve(__dirname, 'canonPrompt.js'); -const CLIENT_PATH = resolve(__dirname, '../../client/src/lib/canonPrompt.js'); - -// Declarations that must be textually identical (code-only, comments stripped) -const MIRRORED_NAMES = [ - 'SHORT_SPEC', - 'PREVIEW_SPEC', - 'RICH_SPEC', - 'normalizeKind', - 'fragmentsFromSequence', - 'sequenceHasAnyField', - 'shortCanonDescriptorFragments', - 'richCanonDescriptorFragments', - 'mapCanonDescriptorFragments', - 'flattenCanonDescriptorFragments', - 'descriptorForCanonEntry', - 'previewCanonFragments', - 'hasCanonDescriptorContent', -]; - -describe('canonPrompt server↔client mirror parity', () => { - const serverSrc = readFileSync(SERVER_PATH, 'utf8'); - const clientSrc = readFileSync(CLIENT_PATH, 'utf8'); - - // Sanity-check that both files were read - it('both files are non-empty', () => { - expect(serverSrc.length).toBeGreaterThan(100); - expect(clientSrc.length).toBeGreaterThan(100); - }); - - // The server file should contain the "Server-only" boundary marker - it('server file has the Server-only boundary comment', () => { - expect(serverSrc).toMatch(/Server-only/); - }); - - for (const name of MIRRORED_NAMES) { - it(`${name} is present and identical on both sides (code only)`, () => { - const { serverDecl, clientDecl, serverNorm, clientNorm } = - compareDeclaration(serverSrc, clientSrc, name); - - expect(serverDecl, `server/lib/canonPrompt.js is missing declaration: ${name}`).not.toBeNull(); - expect(clientDecl, `client/src/lib/canonPrompt.js is missing declaration: ${name}`).not.toBeNull(); - expect(clientNorm, `"${name}" code diverged between server and client`).toBe(serverNorm); - }); - } - - // Ensure server-only exports are NOT present in the client file - const SERVER_ONLY_NAMES = ['flattenStats', 'flattenPalette', 'flattenWardrobes', 'flattenProps', 'flattenNamedList']; - for (const name of SERVER_ONLY_NAMES) { - it(`${name} is server-only (absent from client bundle)`, () => { - const clientDecl = extractDeclaration(clientSrc, name); - expect(clientDecl, `${name} should be server-only but was found in client/src/lib/canonPrompt.js`).toBeNull(); - }); - } -}); diff --git a/server/lib/catalogTypes.js b/server/lib/catalogTypes.js index 659015fc21..2d9112b167 100644 --- a/server/lib/catalogTypes.js +++ b/server/lib/catalogTypes.js @@ -7,9 +7,9 @@ * type guard, the `db.js` / init-db.sql CHECK constraint + FTS field set, and * the three client surfaces) now derives from this one list. * - * Adding a new type becomes: one registry entry here (+ a mirrored client - * entry in `client/src/lib/catalogTypes.js`), one editor field list, and one - * migration that loosens the CHECK constraint. The validation enum, extraction + * Adding a new type becomes: one registry entry here, one editor layout in + * `client/src/lib/catalogTypes.js` (which PROJECTS its UI registry from this + * one), and one migration that loosens the CHECK constraint. The validation enum, extraction * prompt slot, ID prefix, and FTS field set all pick the new type up * automatically. * @@ -30,7 +30,7 @@ * version — this is the per-record payload-shape version. */ -import { BIBLE_LIMITS } from './storyBible.js'; +import { BIBLE_LIMITS } from './bibleLimits.js'; // Structured array-field editors for the bible types. Each entry declares a // payload array key the Catalog detail editor renders as an inline structured @@ -41,8 +41,8 @@ import { BIBLE_LIMITS } from './storyBible.js'; // 'kv' — StatListEditor ({ label, value } rows) // `itemMax`/`listMax` are the per-item char cap + per-list count cap, sourced // from BIBLE_LIMITS so the editor's "disable add at cap" matches the storyBible -// sanitizer's silent drop. MIRRORED to client/src/lib/catalogTypes.js verbatim -// (the parity test asserts they don't drift). +// sanitizer's silent drop. The client registry carries these entries through +// from here, so the caps cannot drift. const CHARACTER_EDITABLE_LIST_FIELDS = [ { key: 'aliases', label: 'Aliases', kind: 'stringArray', itemMax: BIBLE_LIMITS.ALIAS_MAX, listMax: BIBLE_LIMITS.ALIASES_PER_ENTRY_MAX }, { key: 'colorPalette', label: 'Color Palette', kind: 'colorPalette', itemMax: BIBLE_LIMITS.COLOR_NAME_MAX, listMax: BIBLE_LIMITS.COLORS_PER_PALETTE_MAX }, @@ -59,7 +59,7 @@ const OBJECT_EDITABLE_LIST_FIELDS = [ * label — human label (chips, badges, form options). * idPrefix — short token in `cat--` ids. * badgeColor — Tailwind class string for the type chip/badge - * (mirrored verbatim on the client). + * (the client registry projects it through). * primaryContentKey — payload key the inline "New" form writes the body * into (where the type's main prose lives). * primaryContentLabel — label for that field in the inline form. @@ -216,7 +216,7 @@ export function getCatalogType(id) { } // Ordered union of every type's snippet keys — the unknown-type fallback so a -// row whose type isn't in the registry still gets a snippet. Mirrors the client. +// row whose type isn't in the registry still gets a snippet. const UNION_SNIPPET_KEYS = (() => { const out = []; for (const t of CATALOG_TYPES) { @@ -231,9 +231,9 @@ const UNION_SNIPPET_KEYS = (() => { * First non-empty payload value along a type's `snippetFallbackKeys` chain, * trimmed/whitespace-collapsed and truncated to `max` (ellipsis on overflow). * Honors each type's primary content key — e.g. a character's body text lives - * under `physicalDescription`, not `description`. Mirror of the client helper in - * `client/src/lib/catalogTypes.js`; keep the two in sync. `resolveType` lets a - * caller fold in user-defined types (defaults to the built-in registry). + * under `physicalDescription`, not `description`. Re-exported by + * `client/src/lib/catalogTypes.js`. `resolveType` lets a caller fold in + * user-defined types (defaults to the built-in registry). */ export function payloadSnippet(payload, typeId, max = 120, resolveType = null) { if (!payload || typeof payload !== 'object') return ''; @@ -439,8 +439,8 @@ export function canonicalTagKey(label) { * * Drives the `catalog_ingredient_relations.kind` column (an app-layer enum, not * a DB CHECK — same loosening rationale as the type CHECK discussion above) and - * the "Relations" panel on the ingredient detail page. Mirrored on the client - * at `client/src/lib/catalogTypes.js` (drift asserted by the client test). + * the "Relations" panel on the ingredient detail page, which reads it through + * the re-export in `client/src/lib/catalogTypes.js`. * * Each entry: * id — the stored `kind` discriminator. @@ -488,8 +488,8 @@ export function getRelationKind(id) { * the bytes are never duplicated into the catalog. `label` drives the attach * picker; `accept` is the file-input MIME filter for drag-and-drop. The * `portrait` kind is special-cased by `setPortraitMedia` (one active portrait - * per ingredient; attaching a new one demotes the prior). Client mirror lives - * in `client/src/lib/catalogTypes.js` (drift asserted by the type tests). + * per ingredient; attaching a new one demotes the prior). The attach picker + * reads it through the re-export in `client/src/lib/catalogTypes.js`. */ const MEDIA_KIND_REGISTRY = [ { id: 'portrait', label: 'Portrait', accept: 'image/*' }, diff --git a/server/lib/catalogTypes.parity.test.js b/server/lib/catalogTypes.parity.test.js deleted file mode 100644 index f508d68f6b..0000000000 --- a/server/lib/catalogTypes.parity.test.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Cross-package parity for the catalog type registry. - * - * `server/lib/catalogTypes.js` is the source of truth; `client/src/lib/ - * catalogTypes.js` is a hand-maintained mirror (the client can't import the - * server module — it pulls in server-only deps via storyBible.js). This suite - * imports BOTH and asserts the fields the UI relies on stay identical, so a - * server-side registry change that isn't mirrored to the client fails CI here - * instead of silently drifting (badge colors, primary-content keys, snippet - * fallbacks, relation/media kind ids, tag canonicalization). - * - * It lives server-side because the server registry can't load under the client - * (jsdom) runner, but the pure client mirror loads fine here. - */ - -import { describe, it, expect } from 'vitest'; -import { - CATALOG_TYPES as SERVER_TYPES, - RELATION_KINDS as SERVER_REL, - MEDIA_KINDS as SERVER_MEDIA, - canonicalTagKey as serverCanonicalTagKey, -} from './catalogTypes.js'; -import { - CATALOG_TYPES as CLIENT_TYPES, - RELATION_KINDS as CLIENT_REL, - MEDIA_KINDS as CLIENT_MEDIA, - canonicalTagKey as clientCanonicalTagKey, -} from '../../client/src/lib/catalogTypes.js'; - -// The fields the client mirror MUST match the server on — the ones the UI -// reads. `ftsFields` / `extractionShape` / `payloadSchemaVersion` are -// server-only concerns and intentionally not mirrored. -const MIRRORED_FIELDS = ['id', 'label', 'badgeColor', 'primaryContentKey', 'primaryContentLabel', 'snippetFallbackKeys', 'editableListFields']; - -describe('catalog type registry — server↔client parity', () => { - it('exposes the same type ids in the same order', () => { - expect(CLIENT_TYPES.map((t) => t.id)).toEqual(SERVER_TYPES.map((t) => t.id)); - }); - - it('matches every mirrored field for each type', () => { - for (const s of SERVER_TYPES) { - const c = CLIENT_TYPES.find((t) => t.id === s.id); - expect(c, `client mirror missing type ${s.id}`).toBeTruthy(); - for (const f of MIRRORED_FIELDS) { - expect(c[f], `${s.id}.${f} drifted between server and client`).toEqual(s[f]); - } - } - }); - - it('matches relation + media kind ids', () => { - expect(CLIENT_REL.map((r) => r.id)).toEqual(SERVER_REL.map((r) => r.id)); - expect(CLIENT_MEDIA.map((m) => m.id)).toEqual(SERVER_MEDIA.map((m) => m.id)); - }); - - it('canonicalTagKey behaves identically across server and client', () => { - for (const s of ['Film Noir', 'noir', ' Spaced Out ', 'UPPER', 'já-vu', '']) { - expect(clientCanonicalTagKey(s)).toBe(serverCanonicalTagKey(s)); - } - }); -}); diff --git a/server/lib/editorial/letteringDensity.js b/server/lib/editorial/letteringDensity.js index 026c5e3e7a..975d8956b0 100644 --- a/server/lib/editorial/letteringDensity.js +++ b/server/lib/editorial/letteringDensity.js @@ -35,7 +35,7 @@ export const DEFAULT_LETTERING_THRESHOLDS = Object.freeze({ }); // The severity ranks an overflow can scale to (high → low), most-severe first. -// Local copy so this stays self-contained and the client mirror needs nothing +// Local copy so this stays self-contained and the client re-export needs nothing // from checkRegistry. const LETTERING_SEVERITIES = ['high', 'medium', 'low']; diff --git a/server/lib/extensionErrors.js b/server/lib/extensionErrors.js index 7bcc8866bd..f763065372 100644 --- a/server/lib/extensionErrors.js +++ b/server/lib/extensionErrors.js @@ -19,8 +19,8 @@ * (`reject('Failed to connect to MetaMask')` carries no frames at all); see * EXTENSION_MESSAGE_RE below for the bar a new pattern has to clear. * - * This module is MIRRORED at client/src/lib/extensionErrors.js. This server - * copy is authoritative; parity is enforced by extensionErrors.mirror.test.js. + * A pure leaf: client/src/lib/extensionErrors.js re-exports it, so it must + * import no Node built-in and nothing outside `server/lib`. */ // URL schemes an injected content script can run from. Provenance beats diff --git a/server/lib/extensionErrors.mirror.test.js b/server/lib/extensionErrors.mirror.test.js deleted file mode 100644 index 56da22186f..0000000000 --- a/server/lib/extensionErrors.mirror.test.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Mirror parity test for server/lib/extensionErrors.js ↔ client/src/lib/extensionErrors.js - * - * Both ends filter extension errors (each protects its own 1/sec throttle from - * being spent on an un-actionable error), so the two copies must agree on what - * counts as one. A drifted client would send noise the server drops — or worse, - * silently drop a real error the server would have kept. - * - * Comparison strips comments, so the intentionally divergent header commentary - * does not fail the test — only logic does. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { compareDeclaration } from './mirrorParity.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const SERVER_PATH = resolve(__dirname, 'extensionErrors.js'); -const CLIENT_PATH = resolve(__dirname, '../../client/src/lib/extensionErrors.js'); - -const MIRRORED_NAMES = ['EXTENSION_SCHEME_RE', 'EXTENSION_MESSAGE_RE', 'originatingFrame', 'isExtensionError']; - -describe('extensionErrors server↔client mirror parity', () => { - const serverSrc = readFileSync(SERVER_PATH, 'utf8'); - const clientSrc = readFileSync(CLIENT_PATH, 'utf8'); - - it('both files are non-empty', () => { - expect(serverSrc.length).toBeGreaterThan(100); - expect(clientSrc.length).toBeGreaterThan(100); - }); - - for (const name of MIRRORED_NAMES) { - it(`${name} is present and identical on both sides (code only)`, () => { - const { serverDecl, clientDecl, serverNorm, clientNorm } = - compareDeclaration(serverSrc, clientSrc, name); - - expect(serverDecl, `server/lib/extensionErrors.js is missing: ${name}`).not.toBeNull(); - expect(clientDecl, `client/src/lib/extensionErrors.js is missing: ${name}`).not.toBeNull(); - expect( - clientNorm, - `"${name}" diverged — the server copy is authoritative; port the change verbatim`, - ).toBe(serverNorm); - }); - } -}); diff --git a/server/lib/goalFeatureMap.js b/server/lib/goalFeatureMap.js index 82be7704de..ac425d67ec 100644 --- a/server/lib/goalFeatureMap.js +++ b/server/lib/goalFeatureMap.js @@ -7,9 +7,9 @@ // `server/lib/navManifest.js` (`NAV_COMMANDS`) so deep-links can't drift — this // is enforced by `server/lib/goalFeatureMap.test.js`. // -// MIRROR: this file is kept byte-for-byte in sync with -// `server/lib/goalFeatureMap.js` (the server uses it to validate the per-goal -// `featureAreas` override and to build the same rows server-side if needed). +// `client/src/lib/goalFeatureMap.js` re-exports this module, so the picker and +// the server-side validation of the per-goal `featureAreas` override read one +// table. Keep it pure: no Node built-in, nothing outside `server/lib`. // `icon` is a lucide-react icon NAME (string) so this module stays React-free // and importable from server-side tests; the widget resolves the name to a // component at render time. diff --git a/server/lib/importScoping.test.js b/server/lib/importScoping.test.js index 122450712e..86d0ac1779 100644 --- a/server/lib/importScoping.test.js +++ b/server/lib/importScoping.test.js @@ -198,6 +198,13 @@ describe('deferred imports stay deferred (#6156)', () => { // a zero-import leaf (its time units are declared locally precisely so it drags // nothing); the alternative is re-declaring the cadence list at the Zod boundary, // which is the drift that issue exists to close. Fits inside the allowance above. +// +// #6364 retires the server/client copy convention, splitting four pure leaves +// out of modules the client now imports (`bibleLimits.js` out of `storyBible.js`, +// `portosUrls.js` out of `ports.js`, `youtubeUrlAssert.js` out of `youtubeUrl.js`, +// `avatarStyles.js` in from the client tree). Each is one extra NODE on a path +// that already existed — a flatter graph, not a new eager edge into a heavy +// subtree. Fits inside the allowance above. const MAX_STATIC_INSTANTIATIONS = 91400; const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', 'data']); diff --git a/server/lib/index.js b/server/lib/index.js index a38e9792ba..c35f12babb 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -20,6 +20,7 @@ export * from './appDeployFlags.js'; export * from './apiContractSchemas.js'; export * from './asyncApiSpec.js'; export * from './avatarVariants.js'; +export * from './avatarStyles.js'; export * as agentValidation from './agentValidation.js'; export * as agentContextValidation from './agentContextValidation.js'; export * as appleHealthValidation from './appleHealthValidation.js'; @@ -109,6 +110,8 @@ export * from './storyBuilderIntegrity.js'; export * from './storyBuilderSteps.js'; export * from './streamLines.js'; export * from './taskDataInputCatalog.js'; +// The canon field caps storyBible.js sanitizes against, as a pure leaf. +export * from './bibleLimits.js'; // `storyBible.js` re-exports `normalizeSlugline` from `scenePrompt.js` for // back-compat — namespace it so the canonical scenePrompt export wins flat. export * as storyBible from './storyBible.js'; @@ -309,6 +312,7 @@ export * from './pythonSetup.js'; export * from './vttTranscript.js'; export * as youtubeIngestFormat from './youtubeIngestFormat.js'; export * from './youtubeUrl.js'; +export * from './youtubeUrlAssert.js'; export * from './ytdlp.js'; // === Networking === @@ -449,6 +453,7 @@ export * from './pgTools.js'; export * from './platform.js'; export * from './systemCapabilities.js'; export * from './ports.js'; +export * from './portosUrls.js'; export * from './signalCrypto.js'; export * from './timezone.js'; export * from './tribeCadence.js'; diff --git a/server/lib/isSafeHref.js b/server/lib/isSafeHref.js index 1e2642bb0d..a08de68f60 100644 --- a/server/lib/isSafeHref.js +++ b/server/lib/isSafeHref.js @@ -6,9 +6,9 @@ * A stored `javascript:`/`data:`/`vbscript:` URL turns into a stored-XSS * payload the moment it's rendered as an href — validating the scheme at * write time (Zod `.refine`) and re-checking at render time (client) closes - * both the write and the read side. Mirrors `isHttpUrl` in - * `client/src/utils/urlNormalize.js` (kept as two small copies — server and - * client don't share a build step — so keep both in sync if this changes). + * both the write and the read side. `client/src/lib/isSafeHref.js` re-exports + * this module and `client/src/utils/urlNormalize.js` re-exports that as + * `isHttpUrl`, so all three names resolve to one rule. * * @param {string} url * @returns {boolean} diff --git a/server/lib/isSafeHref.test.js b/server/lib/isSafeHref.test.js index d67041f229..b0f98b3cdd 100644 --- a/server/lib/isSafeHref.test.js +++ b/server/lib/isSafeHref.test.js @@ -39,4 +39,15 @@ describe('isSafeHref', () => { it('rejects a garbage non-URL string', () => { expect(isSafeHref('not a url at all')).toBe(false); }); + // The scheme-only spellings a URL parser still reads as http(s) (#6303). These + // used to be the drift between this rule and a hand-maintained client copy — + // the client now imports this module, so they are pinned once, here. + it.each([ + ['https:foo', true], + ['https:/host', true], + ['https:///host', true], + ['//host', false], + ])('reads %p as %p', (input, expected) => { + expect(isSafeHref(input)).toBe(expected); + }); }); diff --git a/server/lib/issueLength.mirror.test.js b/server/lib/issueLength.mirror.test.js deleted file mode 100644 index b49ae390b9..0000000000 --- a/server/lib/issueLength.mirror.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - CUSTOM_MINUTE_MAX as serverMinuteMax, - CUSTOM_MINUTE_MIN as serverMinuteMin, - CUSTOM_PAGE_MAX as serverPageMax, - CUSTOM_PAGE_MIN as serverPageMin, - DEFAULT_LENGTH_PROFILE as serverDefaultProfile, - LENGTH_PROFILES as serverProfiles, -} from './issueLength.js'; -import { - CUSTOM_MINUTE_MAX as clientMinuteMax, - CUSTOM_MINUTE_MIN as clientMinuteMin, - CUSTOM_PAGE_MAX as clientPageMax, - CUSTOM_PAGE_MIN as clientPageMin, - DEFAULT_LENGTH_PROFILE as clientDefaultProfile, - LENGTH_PROFILES as clientProfiles, -} from '../../client/src/lib/issueLength.js'; - -describe('issueLength — server/client picker parity', () => { - it('keeps the profiles the client displays aligned with server targets', () => { - const serverPickerProfiles = Object.fromEntries(Object.entries(serverProfiles).map(([id, profile]) => [ - id, - { - label: profile.label, - pageTarget: profile.pageTarget, - minutesTarget: profile.minutesTarget, - }, - ])); - const clientPickerProfiles = Object.fromEntries(Object.entries(clientProfiles).map(([id, profile]) => [ - id, - { - label: profile.label, - pageTarget: profile.pageTarget, - minutesTarget: profile.minutesTarget, - }, - ])); - - expect(clientPickerProfiles).toEqual(serverPickerProfiles); - expect(clientDefaultProfile).toBe(serverDefaultProfile); - }); - - it('keeps every custom-override bound identical', () => { - expect({ - pageMin: clientPageMin, - pageMax: clientPageMax, - minuteMin: clientMinuteMin, - minuteMax: clientMinuteMax, - }).toEqual({ - pageMin: serverPageMin, - pageMax: serverPageMax, - minuteMin: serverMinuteMin, - minuteMax: serverMinuteMax, - }); - }); -}); diff --git a/server/lib/loraEffect.parity.test.js b/server/lib/loraEffect.parity.test.js deleted file mode 100644 index 2e8e9d7d81..0000000000 --- a/server/lib/loraEffect.parity.test.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Cross-package parity for the LoRA adapter-effect report vocabulary (#4872). - * - * `server/lib/loraEffect.js` is the source of truth — it decides which verdict - * refuses a render and what a measurement is called. `client/src/lib/loraEffect.js` - * mirrors the status list and the summary wording so a manager card and a render - * log describe one measurement the same way. When they disagree the user reads - * one thing on the card and gets another at render time; this suite fails CI - * instead of letting that ship. - * - * It lives server-side because the server module is the authority; both copies - * are pure and load fine under the node runner. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { - LORA_EFFECT_PROBE_VERSION, - LORA_EFFECT_STATUSES as SERVER_STATUSES, - formatLoraEffect as serverFormat, - loraEffectIssue, - normalizeLoraEffectReport, -} from './loraEffect.js'; -import { - LORA_EFFECT_STATUSES as CLIENT_STATUSES, - LORA_EFFECT_BADGES, - formatLoraEffect as clientFormat, - loraEffectBadge, -} from '../../client/src/lib/loraEffect.js'; - -// Every report shape the two formatters must agree on: a plain measurement, one -// with each skip counter, a partially-zero adapter, and the three "no numbers" -// cases (never measured, statistics nulled as non-finite, no reason at all). -const REPORTS = [ - { status: 'ok', measured: 10, medianRms: 0.0031, maxRms: 0.0184, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 0, reason: null }, - { status: 'ok', measured: 8, medianRms: 1e-9, maxRms: 2.5e-8, skippedNonFinite: 2, skippedUnsupported: 0, zeroModules: 0, reason: null }, - { status: 'ok', measured: 8, medianRms: 0.004, maxRms: 0.02, skippedNonFinite: 0, skippedUnsupported: 5, zeroModules: 0, reason: null }, - { status: 'ok', measured: 4, medianRms: 0.004, maxRms: 0.02, skippedNonFinite: 1, skippedUnsupported: 2, zeroModules: 3, reason: null }, - { status: 'zero', measured: 6, medianRms: 0, maxRms: 0, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 6, reason: 'all 6 measurable LoRA module(s) have exactly zero effect' }, - { status: 'unreadable', measured: 0, medianRms: null, maxRms: null, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 0, reason: 'contains no lora_A/lora_B pairs' }, - { status: 'nonfinite', measured: 0, medianRms: null, maxRms: null, skippedNonFinite: 12, skippedUnsupported: 0, zeroModules: 0, reason: 'every module measured NaN' }, - { status: 'unmeasurable', measured: 0, medianRms: null, maxRms: null, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 0, reason: null }, - { status: 'ok', measured: 3, medianRms: null, maxRms: 0.2, skippedNonFinite: 0, skippedUnsupported: 0, zeroModules: 0, reason: null }, -]; - -describe('loraEffect parity — JS vs the Python probe', () => { - it('keeps LORA_EFFECT_PROBE_VERSION in lockstep with PROBE_VERSION', () => { - // Drift here fails silently and expensively: every freshly written report - // reads as stale, so the sidecar cache stops working and each render - // re-reads the whole adapter with nothing logged anywhere. - const source = readFileSync(join(import.meta.dirname, '..', '..', 'scripts', 'lora_effect_probe.py'), 'utf-8'); - const match = source.match(/^PROBE_VERSION = (\d+)$/m); - expect(match, 'scripts/lora_effect_probe.py must declare PROBE_VERSION').not.toBeNull(); - expect(Number(match[1])).toBe(LORA_EFFECT_PROBE_VERSION); - }); - - it('agrees with the probe on the status vocabulary', () => { - const source = readFileSync(join(import.meta.dirname, '..', '..', 'scripts', 'lora_effect_probe.py'), 'utf-8'); - // The probe's own docstring names the five it may emit; a status added on - // one side and not the other degrades to `unmeasurable` at normalization, - // which would quietly disable the verdict rather than fail. - for (const status of Object.values(SERVER_STATUSES)) { - expect(source, `probe never mentions status "${status}"`).toContain(status); - } - }); -}); - -describe('loraEffect parity — server vs client', () => { - it('mirrors the status vocabulary exactly', () => { - expect(CLIENT_STATUSES).toEqual(SERVER_STATUSES); - }); - - it('gives every status a badge, so a new verdict can never render as a bare slug', () => { - expect(Object.keys(LORA_EFFECT_BADGES).sort()).toEqual(Object.values(SERVER_STATUSES).sort()); - for (const status of Object.values(SERVER_STATUSES)) { - expect(loraEffectBadge(status).label).toBeTruthy(); - expect(loraEffectBadge(status).tone).toBeTruthy(); - } - }); - - it('styles exactly the refusing verdict as an error', () => { - // The client must not invent a second blocking-looking status: whichever - // statuses `loraEffectIssue` refuses on are the ones allowed error styling. - const refused = Object.values(SERVER_STATUSES) - .filter((status) => loraEffectIssue({ status, reason: 'x' }) !== null); - const errorStyled = Object.entries(LORA_EFFECT_BADGES) - .filter(([, badge]) => badge.tone.includes('port-error')) - .map(([status]) => status); - expect(errorStyled).toEqual(refused); - expect(refused).toEqual([SERVER_STATUSES.ZERO]); - }); - - it('formats a measured report identically to the server', () => { - // Whenever the server prints statistics, the two must be byte-identical — - // that is the drift this suite exists to catch. - for (const raw of REPORTS) { - const report = normalizeLoraEffectReport(raw); - if (report.measured <= 0 || report.medianRms === null || report.maxRms === null) continue; - expect(clientFormat(report)).toBe(serverFormat(report)); - expect(clientFormat(report)).toContain('median RMS'); - } - }); - - it('drops to the reason (never the badge word) where the server prints its status', () => { - // The server's no-statistics fallback is `status[: reason]`, but the client - // already renders the status as a badge beside this text — echoing it would - // read "Unreadable — Unreadable". So the client contributes the reason, or - // nothing at all, and the card omits the separator. - for (const raw of REPORTS) { - const report = normalizeLoraEffectReport(raw); - if (report.measured > 0 && report.medianRms !== null && report.maxRms !== null) continue; - expect(serverFormat(report).startsWith(report.status)).toBe(true); - expect(clientFormat(report)).toBe(report.reason); - expect(clientFormat(report)).not.toBe(loraEffectBadge(report.status).label); - } - }); - - it('agrees that a null report has nothing to format', () => { - expect(clientFormat(null)).toBeNull(); - expect(serverFormat(null)).toBe('not measured'); - }); -}); diff --git a/server/lib/loraEffect.test.js b/server/lib/loraEffect.test.js index a3cd62af00..dcdb048faa 100644 --- a/server/lib/loraEffect.test.js +++ b/server/lib/loraEffect.test.js @@ -6,6 +6,8 @@ * sidecar cache and a UI badge all depend on being identical. */ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; import { LORA_EFFECT_PROBE_VERSION, LORA_EFFECT_STATUSES, @@ -243,3 +245,29 @@ describe('formatLoraEffect', () => { .toBe('ok'); }); }); + +describe('loraEffect — JS vs the Python probe', () => { + const probeSource = () => readFileSync( + join(import.meta.dirname, '..', '..', 'scripts', 'lora_effect_probe.py'), + 'utf-8', + ); + + it('keeps LORA_EFFECT_PROBE_VERSION in lockstep with PROBE_VERSION', () => { + // Drift here fails silently and expensively: every freshly written report + // reads as stale, so the sidecar cache stops working and each render + // re-reads the whole adapter with nothing logged anywhere. + const match = probeSource().match(/^PROBE_VERSION = (\d+)$/m); + expect(match, 'scripts/lora_effect_probe.py must declare PROBE_VERSION').not.toBeNull(); + expect(Number(match[1])).toBe(LORA_EFFECT_PROBE_VERSION); + }); + + it('agrees with the probe on the status vocabulary', () => { + // The probe's own docstring names the five it may emit; a status added on + // one side and not the other degrades to `unmeasurable` at normalization, + // which would quietly disable the verdict rather than fail. + const source = probeSource(); + for (const status of Object.values(LORA_EFFECT_STATUSES)) { + expect(source, `probe never mentions status "${status}"`).toContain(status); + } + }); +}); diff --git a/server/lib/loraTriggers.parity.test.js b/server/lib/loraTriggers.parity.test.js deleted file mode 100644 index 149c3f6e26..0000000000 --- a/server/lib/loraTriggers.parity.test.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Cross-package parity for the LoRA trigger-word predicates (#4665). - * - * `server/lib/loraTriggers.js` is the source of truth — it decides what the - * runner actually renders. `client/src/lib/loraTriggers.js` mirrors the two - * predicates the UI needs so the LoRA picker's "this token will be added" hint - * and ImageGen's "+ trigger" dedupe agree with what the server will do. If the - * two disagree, the picker tells the user one thing and the render does - * another — this suite fails CI instead of letting that ship. - * - * It lives server-side because the server module is the authority; both copies - * are pure and load fine under the node runner. - */ - -import { describe, it, expect } from 'vitest'; -import { - firstTriggerWord as serverFirstTriggerWord, - promptHasTriggerWord as serverPromptHasTriggerWord, - separatorFor as serverSeparatorFor, - weaveLoraTriggers, -} from './loraTriggers.js'; -import { - firstTriggerWord as clientFirstTriggerWord, - promptHasTriggerWord as clientPromptHasTriggerWord, - separatorFor as clientSeparatorFor, - appendTriggerWords as clientAppendTriggerWords, -} from '../../client/src/lib/loraTriggers.js'; - -// Every shape the two implementations must agree on: the whole-token boundary -// (`aria_tok` vs `aria_token`), multi-word Civitai phrases, regex -// metacharacters, case-insensitivity, and the empty/absent inputs. -const PRESENCE_CASES = [ - ['a portrait of Aria_Tok on a rooftop', 'aria_tok'], - ['a portrait of aria_token', 'aria_tok'], - ['concatenate the frames', 'cat'], - ['an audio reactive visualizer', 'audio reactive'], - ['style: c.a.t art', 'c.a.t'], - ['style: cXaYt art', 'c.a.t'], - ['a portrait of ariaé', 'aria'], - ['a portrait of éclairs', 'éclair'], - ['a portrait of éclair', 'éclair'], - ['aria_tok, rooftop', 'aria_tok'], - ['rooftop', 'aria_tok'], - ['', 'aria_tok'], - ['a prompt', ''], - [null, 'aria_tok'], - ['a prompt', null], -]; - -const FIRST_WORD_CASES = [ - [' aria_tok ', 'portrait'], - ['', ' ', 'rstgrm'], - [], - null, - 'aria_tok', - [null, 42], -]; - -describe('LoRA trigger words — server↔client parity', () => { - it('agrees on whether a word is already present in a prompt', () => { - for (const [prompt, word] of PRESENCE_CASES) { - expect( - clientPromptHasTriggerWord(prompt, word), - `promptHasTriggerWord drifted for prompt=${JSON.stringify(prompt)} word=${JSON.stringify(word)}`, - ).toBe(serverPromptHasTriggerWord(prompt, word)); - } - }); - - it('agrees on which word activates a LoRA', () => { - for (const words of FIRST_WORD_CASES) { - expect( - clientFirstTriggerWord(words), - `firstTriggerWord drifted for ${JSON.stringify(words)}`, - ).toEqual(serverFirstTriggerWord(words)); - } - }); - - it('agrees on how a trigger clause attaches to the end of a prompt', () => { - // The server weave and the client's '+ trigger' button both append. Whichever - // lands the token first makes the other a no-op, so a separator that drifts - // means one of them can bury the activation token in a trailing directive - // with nothing downstream to repair it. - const SEPARATOR_CASES = [ - 'a rooftop at dusk', - 'a rooftop at dusk,', - '', - 'cinematic. a rooftop\n\nno music, no soundtrack', - 'a rooftop\nsecond line', - ]; - for (const trimmed of SEPARATOR_CASES) { - expect( - clientSeparatorFor(trimmed), - `separatorFor drifted for ${JSON.stringify(trimmed)}`, - ).toBe(serverSeparatorFor(trimmed)); - } - }); - - it('pins the multi-paragraph rule in BOTH the weave and the button', () => { - // A mutual regression would slip past the equality check above, so pin the - // behavior: a token must never join a trailing negation list. - const multi = 'a rooftop at dusk\n\nno text, no watermark'; - expect(weaveLoraTriggers(multi, [['aria_tok']]).prompt) - .toBe(`${multi}\n\naria_tok`); - expect(clientAppendTriggerWords(multi, ['aria_tok'])) - .toBe(`${multi}\n\naria_tok`); - }); - - it('pins the boundary rule both copies encode (not just that they match)', () => { - // A mutual regression — both copies losing the boundary together — would - // slip past the equality assertions above, so pin the behavior itself. - for (const has of [serverPromptHasTriggerWord, clientPromptHasTriggerWord]) { - expect(has('a portrait of aria_token', 'aria_tok')).toBe(false); - expect(has('a portrait of aria_tok', 'aria_tok')).toBe(true); - expect(has('a portrait of ariaé', 'aria')).toBe(false); - } - for (const first of [serverFirstTriggerWord, clientFirstTriggerWord]) { - expect(first(['rstgrm', 'film grain'])).toBe('rstgrm'); - } - }); -}); diff --git a/server/lib/mirrorCoverage.test.js b/server/lib/mirrorCoverage.test.js deleted file mode 100644 index d58bab8d48..0000000000 --- a/server/lib/mirrorCoverage.test.js +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync, readdirSync } from 'fs'; -import { basename, dirname, join } from 'path'; -import { fileURLToPath } from 'url'; -import { escapeRegExp } from './textUtils.js'; - -const here = dirname(fileURLToPath(import.meta.url)); -const CLIENT_LIB = join(here, '../../client/src/lib'); -const CLIENT_README = join(CLIENT_LIB, 'README.md'); -const SERVER_README = join(here, 'README.md'); - -function listTestFiles(dir) { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const path = join(dir, entry.name); - if (entry.isDirectory()) return listTestFiles(path); - return entry.name.endsWith('.test.js') ? [path] : []; - }); -} - -// Both catalogs declare mirrors the same way — a backtick-fenced filename in -// column 1, a description in column 2 naming the counterpart path — differing -// only in which side is "this file" vs. "the other file it mirrors". One -// parameterized walker keeps that row-parsing logic from drifting between the -// two catalogs the way the mirrored declarations it guards must not drift. -function listedPairsFor(readme, otherPathRe) { - const rows = [...readme.matchAll(/^\|\s+`([^`]+\.js)`\s+\|\s+(.+)\|$/gm)]; - return rows.flatMap(([, thisFile, description]) => { - if (!/\bmirror/i.test(description)) return []; - const otherMatch = description.match(otherPathRe); - if (!otherMatch || otherMatch[1].includes('/') || thisFile !== basename(otherMatch[1])) return []; - return [{ thisFile, otherFile: otherMatch[1] }]; - }); -} - -function listedMirrorPairs(readme) { - return listedPairsFor(readme, /server\/lib\/([\w/-]+\.js)/) - .map(({ thisFile, otherFile }) => ({ clientFile: thisFile, serverFile: otherFile })); -} - -function listedServerMirrorPairs(readme) { - return listedPairsFor(readme, /client\/src\/lib\/([\w/-]+\.js)/) - .map(({ thisFile, otherFile }) => ({ clientFile: otherFile, serverFile: thisFile })); -} - -function uniquePairs(pairs) { - return [...new Map(pairs.map((pair) => [`${pair.serverFile}:${pair.clientFile}`, pair])).values()]; -} - -// Matches `ref` only when it appears as (part of) a quoted string — i.e. an -// actual import/require specifier — not a bare substring. A prose comment -// mentioning a filename, or an unrelated same-prefix fixture, must not count -// as "this test imports that file". -function importsRef(source, ref) { - const escaped = escapeRegExp(ref); - // Quote characters only — no backtick. Backtick-fenced prose is this - // codebase's dominant style for referencing a file path in a comment or - // JSDoc header (see every existing parity-test docstring), so treating it - // as an import specifier would reopen the exact prose-mention bypass this - // helper exists to close. - return new RegExp(`['"][^'"]*${escaped}['"]`).test(source); -} - -function missingParityPins(pairs, testSources) { - return pairs.filter(({ clientFile, serverFile }) => { - const serverName = basename(serverFile); - // For a direct mirror, clientFile and serverName are the identical - // string — so "reads the client copy" and "reads the server copy" can't - // be proven by a plain check on each independently, or the SAME - // occurrence satisfies both (a client-only test that only imports its - // own module via `'./example.js'` would otherwise pass as a valid - // parity pin, and symmetrically for a server-only test — see the - // bypass-probe tests below). Strip whichever string just proved "reads - // the client copy" before checking for server-copy evidence, so the two - // proofs must come from genuinely different occurrences. - const clientPathRef = `client/src/lib/${clientFile}`; - return !testSources.some(({ path, source }) => { - if (path === fileURLToPath(import.meta.url)) return false; - const inClientLib = path.startsWith(CLIENT_LIB); - const readsClient = importsRef(source, clientPathRef) || (inClientLib && importsRef(source, clientFile)); - if (!readsClient) return false; - let remainder = source.split(clientPathRef).join(''); - // Only strip the bare-import specifier when it was actually used as - // client-copy evidence above (inClientLib) — outside client/src/lib - // that same specifier is exactly what proves the SERVER copy was read - // (see catalogTypes.parity.test.js: `from './catalogTypes.js'` - // alongside the full client path), so stripping it unconditionally - // would erase legitimate server-copy evidence. - if (inClientLib) remainder = remainder.split(`'./${clientFile}'`).join('').split(`"./${clientFile}"`).join(''); - return importsRef(remainder, serverName); - }); - }); -} - -describe('declared server/client mirror coverage', () => { - const readme = readFileSync(CLIENT_README, 'utf8'); - const pairs = uniquePairs([ - ...listedMirrorPairs(readme), - ...listedServerMirrorPairs(readFileSync(SERVER_README, 'utf8')), - ]); - const testSources = [...listTestFiles(here), ...listTestFiles(CLIENT_LIB)].map((path) => ({ - path, - source: readFileSync(path, 'utf8'), - })); - - it('finds direct same-name mirror declarations in the client catalog', () => { - expect(pairs).toContainEqual({ clientFile: 'seasonStructure.js', serverFile: 'seasonStructure.js' }); - expect(pairs).toContainEqual({ clientFile: 'shotGrammar.js', serverFile: 'shotGrammar.js' }); - expect(pairs).toContainEqual({ clientFile: 'appIdentity.js', serverFile: 'appIdentity.js' }); - expect(pairs).toContainEqual({ clientFile: 'issueLength.js', serverFile: 'issueLength.js' }); - }); - - it('also includes direct same-name declarations from the server catalog', () => { - expect(pairs).toContainEqual({ clientFile: 'catalogTypes.js', serverFile: 'catalogTypes.js' }); - }); - - it('requires every declared direct mirror to have a test that reads both copies', () => { - const missing = missingParityPins(pairs, testSources); - expect(missing, `missing parity pins: ${missing.map(({ clientFile }) => clientFile).join(', ')}`).toEqual([]); - }); - - it('reports a synthetic declared mirror when no test reads both copies', () => { - const synthetic = listedMirrorPairs('| `example.js` | Mirror of `server/lib/example.js`. |'); - expect(missingParityPins(synthetic, [])).toEqual([ - { clientFile: 'example.js', serverFile: 'example.js' }, - ]); - }); - - it('does not accept a same-name server-only unit test as a parity pin (bypass probe)', () => { - // A direct mirror's clientFile and serverFile are the same string, so a - // plain server-side unit test importing its own module via a bare - // relative path (e.g. `bareUrl.test.js` doing `from './bareUrl.js'`) - // trivially contains both `'./example.js'` and the server filename - // without ever touching the client copy. Pin that this does NOT count. - const synthetic = listedMirrorPairs('| `example.js` | Mirror of `server/lib/example.js`. |'); - const serverOnlyUnitTest = { - path: join(here, 'example.test.js'), - source: "import { thing } from './example.js';\n", - }; - expect(missingParityPins(synthetic, [serverOnlyUnitTest])).toEqual([ - { clientFile: 'example.js', serverFile: 'example.js' }, - ]); - }); - - it('does not accept a same-name client-only unit test as a parity pin (bypass probe)', () => { - // The mirror image of the probe above: a plain client-side unit test - // importing its own module via a bare relative path (e.g. - // client/src/lib/catalogTypes.test.js doing `from './catalogTypes.js'`) - // never touches the server copy. Pin that this does NOT count either. - const synthetic = listedMirrorPairs('| `example.js` | Mirror of `server/lib/example.js`. |'); - const clientOnlyUnitTest = { - path: join(CLIENT_LIB, 'example.test.js'), - source: "import { thing } from './example.js';\n", - }; - expect(missingParityPins(synthetic, [clientOnlyUnitTest])).toEqual([ - { clientFile: 'example.js', serverFile: 'example.js' }, - ]); - }); - - it('does not accept a bare prose mention of the filename as a parity pin (bypass probe)', () => { - // A comment mentioning the server path in passing — without an actual - // import of it — must not satisfy the guard either, or a stray comment - // surviving the deletion of the real parity-pinning test would keep this - // suite silently green. - const synthetic = listedMirrorPairs('| `example.js` | Mirror of `server/lib/example.js`. |'); - const commentOnlyMention = { - path: join(CLIENT_LIB, 'example.test.js'), - source: "import { thing } from './example.js';\n// keep this in sync with server/lib/example.js\n", - }; - expect(missingParityPins(synthetic, [commentOnlyMention])).toEqual([ - { clientFile: 'example.js', serverFile: 'example.js' }, - ]); - }); - - it('does not accept a backtick-fenced prose mention as a parity pin (bypass probe)', () => { - // Backtick-fenced file references are this codebase's dominant docstring - // style (see every existing parity-test header) — a JSDoc comment that - // mentions both paths in backticks, with no real import backing it, must - // not count either. - const synthetic = listedMirrorPairs('| `example.js` | Mirror of `server/lib/example.js`. |'); - const backtickOnlyMention = { - path: join(CLIENT_LIB, 'example.test.js'), - source: 'import { thing } from \'./example.js\';\n// mirrors `server/lib/example.js` in spirit only, no import here.\n', - }; - expect(missingParityPins(synthetic, [backtickOnlyMention])).toEqual([ - { clientFile: 'example.js', serverFile: 'example.js' }, - ]); - }); -}); diff --git a/server/lib/mirrorParity.js b/server/lib/mirrorParity.js index 12688d5313..4c128ad4ee 100644 --- a/server/lib/mirrorParity.js +++ b/server/lib/mirrorParity.js @@ -1,12 +1,18 @@ /** * Shared source-comparison helpers for server↔client "mirror" parity tests. * - * Several client modules are byte-for-byte mirrors of an authoritative server - * module (see the "server mirrors" section of client/src/lib/README.md). Each - * mirror is pinned by a `.mirror.test.js` that extracts the mirrored + * A shared PURE module is imported, not copied — the client re-exports the + * `server/lib` leaf (see "One pure module, one definition" in + * client/src/lib/README.md), so it needs no parity test at all. What is left are + * the pairs that cannot be one module: a vocabulary restated in a React + * component's constants file, or a table a service and a page each own. Each of + * those is pinned by a `.mirror.test.js` that extracts the mirrored * declarations from both files and diffs them with comments stripped, so * per-side commentary can diverge but logic cannot. * + * Reach for this only when the two sides genuinely cannot be one module. If they + * can, move the logic into a pure `server/lib` leaf and delete the copy. + * * Every such test needs the same two primitives, and hand-rolling them per * mirror means a bug in the brace-walker has to be found and fixed once per * copy — copies that had already drifted (one handled `async function`, the diff --git a/server/lib/musicDuration.js b/server/lib/musicDuration.js index b9a428bd1e..640e114c69 100644 --- a/server/lib/musicDuration.js +++ b/server/lib/musicDuration.js @@ -1,4 +1,5 @@ -// Server-side mirror of `client/src/lib/musicDuration.js`. MiniMax Music 3 +// The authoritative copy, re-exported by `client/src/lib/musicDuration.js` +// (keep it pure: no Node built-in, nothing outside `server/lib`). MiniMax Music 3 // treats audio_duration as a ceiling, so auto mode sizes that ceiling from the // lyric structure and leaves room for the composition to resolve. diff --git a/server/lib/musicDuration.mirror.test.js b/server/lib/musicDuration.mirror.test.js deleted file mode 100644 index ed004b08fd..0000000000 --- a/server/lib/musicDuration.mirror.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Cross-package parity for the MiniMax Music 3 duration recommendation. - * - * The server recomputes the recommendation so requests cannot bypass the - * client ceiling, while the client uses the same analyzer for immediate UI - * feedback. Import both copies here so a change to one side cannot silently - * change the suggested duration or structure warnings on the other. - */ - -import { describe, expect, it } from 'vitest'; -import * as serverDuration from './musicDuration.js'; -import * as clientDuration from '../../client/src/lib/musicDuration.js'; - -const MIRRORED_CONSTANTS = [ - 'MINIMAX_AUTO_MIN_DURATION_SEC', - 'MINIMAX_AUTO_MAX_DURATION_SEC', - 'MINIMAX_AUTO_DURATION_STEP_SEC', -]; - -const FIXTURES = [ - ['[verse]\nrain on the window\n[chorus]\nsing it loud\n[outro]', {}], - ['[verse] keep inline text\nplain words\n[outro] last line', {}], - ['one two three', { minDurationSec: 12, maxDurationSec: 30 }], - ['', {}], -]; - -describe('music duration server↔client mirror parity', () => { - it('keeps the shared constants identical', () => { - for (const name of MIRRORED_CONSTANTS) { - expect(clientDuration[name], `${name} drifted between client and server`).toBe(serverDuration[name]); - } - }); - - it('produces identical lyric analysis and recommendations', () => { - for (const [lyrics, options] of FIXTURES) { - expect(clientDuration.analyzeMusicLyrics(lyrics, options)).toEqual( - serverDuration.analyzeMusicLyrics(lyrics, options), - ); - expect(clientDuration.recommendMinimaxDurationSec(lyrics, options)).toBe( - serverDuration.recommendMinimaxDurationSec(lyrics, options), - ); - } - }); -}); diff --git a/server/lib/personaTraitBlend.js b/server/lib/personaTraitBlend.js index 201737d49f..95b0d88c15 100644 --- a/server/lib/personaTraitBlend.js +++ b/server/lib/personaTraitBlend.js @@ -12,13 +12,14 @@ * preamble (see `digital-twin-context.js`), so the embodied twin shifts voice * per context without forking the underlying identity documents. * - * Pure ESM, no Node-only deps — mirrored byte-for-byte to - * `client/src/lib/personaTraitBlend.js` so the Personas UI can preview the same - * directional wording. The server copy is authoritative; the matching server - * test file (`personaTraitBlend.test.js`) is the contract. + * Pure ESM, no Node-only deps — `client/src/lib/personaTraitBlend.js` re-exports + * it so the Personas UI previews the same directional wording from the same + * code. `personaTraitBlend.test.js` is the contract. */ -import { clamp } from '../../client/src/utils/formatters.js'; +// Local rather than imported: this leaf is loaded by the browser bundle through +// the client re-export, so it must not reach outside `server/lib`. +const clamp = (n, min, max) => Math.min(max, Math.max(min, n)); // communicationProfile.formality / .verbosity live on a 1..10 scale; a persona // nudges them with a relative integer delta in this range. diff --git a/server/lib/personaTraitBlend.parity.test.js b/server/lib/personaTraitBlend.parity.test.js deleted file mode 100644 index aba98c685b..0000000000 --- a/server/lib/personaTraitBlend.parity.test.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Byte-for-byte parity for the persona trait-blend helper. - * - * `server/lib/personaTraitBlend.js` is the source of truth; `client/src/lib/ - * personaTraitBlend.js` is a mirror the Personas UI imports for its live voice - * preview. The module's docstring promises the two stay byte-identical (so the - * preview wording matches the directive the embodied twin actually sees) — this - * suite enforces that promise: an edit to one copy that isn't mirrored fails CI - * here instead of letting the UI silently diverge from the server directive. - * - * It lives server-side because the parity check only needs filesystem reads, - * which the server (node) runner has. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const here = dirname(fileURLToPath(import.meta.url)); -const SERVER_COPY = join(here, 'personaTraitBlend.js'); -const CLIENT_COPY = join(here, '../../client/src/lib/personaTraitBlend.js'); - -describe('personaTraitBlend — server↔client byte parity', () => { - it('keeps the client mirror byte-for-byte identical to the server source', () => { - const server = readFileSync(SERVER_COPY, 'utf-8'); - const client = readFileSync(CLIENT_COPY, 'utf-8'); - const normalizedServer = server.replace('../../client/src/utils/formatters.js', '../utils/formatters.js'); - expect(client).toBe(normalizedServer); - }); -}); diff --git a/server/lib/portosUrls.js b/server/lib/portosUrls.js new file mode 100644 index 0000000000..4356f76a40 --- /dev/null +++ b/server/lib/portosUrls.js @@ -0,0 +1,12 @@ +// The origins PortOS prints into prompts and links, resolved from the +// environment at module load. +// +// Split out of `ports.js` so that module stays a pure leaf the browser bundle +// can import through `client/src/lib/ports.js`: a module-scope `process.env` +// read throws `process is not defined` in the client build. +import { PORTS } from './ports.js'; + +export const PORTOS_UI_URL = process.env.PORTOS_UI_URL + || `http://${process.env.PORTOS_HOST || 'localhost'}:${PORTS.UI}`; +export const PORTOS_API_URL = process.env.PORTOS_API_URL + || `http://${process.env.PORTOS_HOST || 'localhost'}:${process.env.PORT || PORTS.API}`; diff --git a/server/lib/ports.js b/server/lib/ports.js index 0bae117bb8..4b287cf2b2 100644 --- a/server/lib/ports.js +++ b/server/lib/ports.js @@ -1,7 +1,15 @@ // Importable mirror of the `PORTS` object in ecosystem.config.cjs (the source of // truth — see docs/PORTS.md). ESM server code can't require() the CommonJS -// ecosystem config, so these literals are duplicated here and must stay in sync. -export const PORTS = { +// ecosystem config, so these literals are duplicated here and must stay in sync +// (`ports.test.js` fails when they drift). +// +// A pure leaf: `client/src/lib/ports.js` re-exports it, so this module must read +// no `process.env` at module scope and import nothing outside `server/lib`. The +// env-derived origins live in `portosUrls.js` for that reason. +// +// Frozen: the client re-exports this object, and a UI that could mutate a shared +// port map would change what every later reader resolves. +export const PORTS = Object.freeze({ API: 5555, // HTTPS API (or HTTP if cert not configured) API_LOCAL: 5553, // Loopback-only HTTP mirror — only binds when HTTPS is active on API. // Tailscale cert covers ..ts.net only, so @@ -24,7 +32,7 @@ export const PORTS = { VLLM_QWEN: 18020, // Loopback vLLM Qwen3.8-27B (DFlash 2) container — opt-in dedicated host setup SGLANG_QWEN: 18021, // Loopback SGLang Qwen3.8-27B container (Hopper/Blackwell) — operator-started, never by PortOS POSTGRES_NATIVE: 5432 // System PostgreSQL (PGMODE=native) -}; +}); // The ecosystem config resolves a single active `PORTS.POSTGRES` by reading // PGMODE out of .env at load time. This module stays free of filesystem reads @@ -34,7 +42,3 @@ export const resolvePostgresPort = (pgMode) => (pgMode === 'native' ? PORTS.POSTGRES_NATIVE : PORTS.POSTGRES_DOCKER); export const DEFAULT_PEER_PORT = PORTS.API; -export const PORTOS_UI_URL = process.env.PORTOS_UI_URL - || `http://${process.env.PORTOS_HOST || 'localhost'}:${PORTS.UI}`; -export const PORTOS_API_URL = process.env.PORTOS_API_URL - || `http://${process.env.PORTOS_HOST || 'localhost'}:${process.env.PORT || PORTS.API}`; diff --git a/server/lib/ports.test.js b/server/lib/ports.test.js index dfe5dfed35..4ae9813972 100644 --- a/server/lib/ports.test.js +++ b/server/lib/ports.test.js @@ -3,7 +3,7 @@ import { createRequire } from 'module'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import path from 'path'; -import { PORTS, resolvePostgresPort } from './ports.js'; +import { DEFAULT_PEER_PORT, PORTS, resolvePostgresPort } from './ports.js'; // `ecosystem.config.cjs` is the source of truth for port numbers; `ports.js` is a // hand-maintained ESM mirror of it (the ESM server can't require() the CJS @@ -56,6 +56,10 @@ describe('PORTS mirror of ecosystem.config.cjs', () => { expect(Number(branches[1])).toBe(PORTS.POSTGRES_NATIVE); expect(Number(branches[2])).toBe(PORTS.POSTGRES_DOCKER); }); + + it('defaults a new peer to the API port', () => { + expect(DEFAULT_PEER_PORT).toBe(ECOSYSTEM_PORTS.API); + }); }); describe('resolvePostgresPort', () => { diff --git a/server/lib/postRotation.js b/server/lib/postRotation.js index adbda7b583..eed438d65b 100644 --- a/server/lib/postRotation.js +++ b/server/lib/postRotation.js @@ -8,10 +8,10 @@ * the choice varies across days while staying repeatable for the same day and * the same inputs (no randomness — the daily routine must be reproducible). * - * Pure, dependency-free, and MIRRORED to `client/src/lib/postRotation.js` so the - * server's recommendation tiers and the client's Quick-session domain picks - * rotate identically. Keep the two files in sync — `postRotation.mirror.test.js` - * fails when their code diverges. + * Pure and dependency-free: `client/src/lib/postRotation.js` re-exports it, so + * the server's recommendation tiers and the client's Quick-session domain picks + * rotate identically because they run the same code. Import no Node built-in + * here, and nothing outside `server/lib`. */ /** diff --git a/server/lib/postRotation.mirror.test.js b/server/lib/postRotation.mirror.test.js deleted file mode 100644 index b020ca785f..0000000000 --- a/server/lib/postRotation.mirror.test.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Mirror parity test for server/lib/postRotation.js ↔ client/src/lib/postRotation.js - * - * The whole module is mirrored: the server's recommendation tiers and the - * client's Quick-session domain picks must rotate identically, or the two - * surfaces disagree about which drill is "next" on the same day. - * - * Comments are stripped before diffing (see lib/mirrorParity.js), so the header - * pointing at the twin file may differ; code logic may not. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { compareDeclaration } from './mirrorParity.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const SERVER_PATH = resolve(__dirname, 'postRotation.js'); -const CLIENT_PATH = resolve(__dirname, '../../client/src/lib/postRotation.js'); - -const MIRRORED_NAMES = ['dayOrdinal', 'dayRotationIndex', 'orderByRecencyRotation']; - -describe('postRotation server↔client mirror parity', () => { - const serverSrc = readFileSync(SERVER_PATH, 'utf8'); - const clientSrc = readFileSync(CLIENT_PATH, 'utf8'); - - for (const name of MIRRORED_NAMES) { - it(`${name} is present and identical on both sides (code only)`, () => { - const { serverDecl, clientDecl, serverNorm, clientNorm } = - compareDeclaration(serverSrc, clientSrc, name); - - expect(serverDecl, `server/lib/postRotation.js is missing declaration: ${name}`).not.toBeNull(); - expect(clientDecl, `client/src/lib/postRotation.js is missing declaration: ${name}`).not.toBeNull(); - expect(clientNorm, `"${name}" code diverged between server and client`).toBe(serverNorm); - }); - } -}); diff --git a/server/lib/repoUrl.js b/server/lib/repoUrl.js index 40a8f17a2c..2804606c3f 100644 --- a/server/lib/repoUrl.js +++ b/server/lib/repoUrl.js @@ -9,8 +9,9 @@ * agent options (malware scan / learn-from-repo) when it agrees with the server * about what counts as a repo. * - * The client mirror is `client/src/lib/repoUrl.js`; parity is enforced by - * `server/lib/repoUrl.mirror.test.js`. Port any change to both. + * A pure leaf: `client/src/lib/repoUrl.js` re-exports it, so the Brain capture + * boxes reveal the post-clone agent options for exactly the URLs the server will + * clone. Import no Node built-in here, and nothing outside `server/lib`. */ // The host allowlist, and the two behaviors that actually differ between hosts. diff --git a/server/lib/repoUrl.mirror.test.js b/server/lib/repoUrl.mirror.test.js deleted file mode 100644 index 50732dc7b6..0000000000 --- a/server/lib/repoUrl.mirror.test.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Mirror parity test for server/lib/repoUrl.js ↔ client/src/lib/repoUrl.js - * - * The server decides whether a captured URL gets cloned; the client previews - * that decision by revealing the post-clone agent options (malware scan / - * learn-from-repo) only for a repo URL. A drifted client either offers those - * options for a link that will never be cloned, or hides them for one that will. - * - * Comparison strips comments, so the intentionally divergent header commentary - * does not fail the test — only logic does. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { compareDeclaration } from './mirrorParity.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const SERVER_PATH = resolve(__dirname, 'repoUrl.js'); -const CLIENT_PATH = resolve(__dirname, '../../client/src/lib/repoUrl.js'); - -const MIRRORED_NAMES = [ - 'REPO_HOSTS', - 'OWNER_RE', - 'REPO_RE', - 'DOT_SEGMENTS', - 'SSH_RE', - 'HTTP_RE', - 'RESERVED_PATH_SEGMENTS', - 'parseRepoUrl', - 'isRepoUrl', - 'repoBrowseUrl', - 'parseGitHubUrl', - 'isGitHubRepoUrl', -]; - -describe('repoUrl server↔client mirror parity', () => { - const serverSrc = readFileSync(SERVER_PATH, 'utf8'); - const clientSrc = readFileSync(CLIENT_PATH, 'utf8'); - - it('both files are non-empty', () => { - expect(serverSrc.length).toBeGreaterThan(100); - expect(clientSrc.length).toBeGreaterThan(100); - }); - - for (const name of MIRRORED_NAMES) { - it(`${name} is present and identical on both sides (code only)`, () => { - const { serverDecl, clientDecl, serverNorm, clientNorm } = - compareDeclaration(serverSrc, clientSrc, name); - - expect(serverDecl, `server/lib/repoUrl.js is missing: ${name}`).not.toBeNull(); - expect(clientDecl, `client/src/lib/repoUrl.js is missing: ${name}`).not.toBeNull(); - expect( - clientNorm, - `"${name}" diverged — the server copy is authoritative; port the change verbatim`, - ).toBe(serverNorm); - }); - } -}); diff --git a/server/lib/scenePrompt.test.js b/server/lib/scenePrompt.test.js index 37c3f4c59e..4cd1bdd7be 100644 --- a/server/lib/scenePrompt.test.js +++ b/server/lib/scenePrompt.test.js @@ -1,7 +1,4 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; import { normalizeSlugline, normCharKey, @@ -12,11 +9,6 @@ import { buildScenePrompt, __testing, } from './scenePrompt.js'; -import { compareDeclaration } from './mirrorParity.js'; - -const here = dirname(fileURLToPath(import.meta.url)); -const SERVER_COPY = join(here, 'scenePrompt.js'); -const CLIENT_COPY = join(here, '../../client/src/lib/scenePrompt.js'); describe('scenePrompt — normalizeSlugline', () => { it('collapses em/en/hyphen + punctuation + spaces so equivalent sluglines match', () => { @@ -314,32 +306,4 @@ describe('scenePrompt — buildScenePrompt wardrobe appearances', () => { expect(out).toContain('Aria: tall, dark hair'); expect(out).not.toContain('Wearing:'); }); -}); - -describe('scenePrompt — server/client mirror parity', () => { - const server = readFileSync(SERVER_COPY, 'utf8'); - const client = readFileSync(CLIENT_COPY, 'utf8'); - const mirroredDeclarations = [ - 'PROMPT_MAX', - 'normalizeSlugline', - 'normCharKey', - 'buildCharByKey', - 'matchSceneCharacters', - 'matchCharactersInText', - 'buildPlaceByKey', - 'matchScenePlace', - 'matchEntriesByCandidates', - 'matchPlacesInText', - 'matchObjectsInText', - 'appendWardrobe', - 'buildScenePrompt', - ]; - - for (const name of mirroredDeclarations) { - it(`keeps ${name} identical`, () => { - const { clientDecl, serverNorm, clientNorm } = compareDeclaration(server, client, name); - expect(clientDecl, `client/src/lib/scenePrompt.js is missing ${name}`).not.toBeNull(); - expect(clientNorm).toBe(serverNorm); - }); - } -}); +}); \ No newline at end of file diff --git a/server/lib/seasonStructure.mirror.test.js b/server/lib/seasonStructure.mirror.test.js deleted file mode 100644 index 17f34d4612..0000000000 --- a/server/lib/seasonStructure.mirror.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; -import { compareDeclaration } from './mirrorParity.js'; - -const here = dirname(fileURLToPath(import.meta.url)); -const SERVER_COPY = join(here, 'seasonStructure.js'); -const CLIENT_COPY = join(here, '../../client/src/lib/seasonStructure.js'); -const MIRRORED_DECLARATIONS = ['pickSeasonCount', 'recommendStructure', 'describeStructure']; - -describe('seasonStructure — server/client mirror parity', () => { - const server = readFileSync(SERVER_COPY, 'utf8'); - const client = readFileSync(CLIENT_COPY, 'utf8'); - - for (const name of MIRRORED_DECLARATIONS) { - it(`keeps ${name} identical`, () => { - const { clientDecl, serverNorm, clientNorm } = compareDeclaration(server, client, name); - expect(clientDecl, `client/src/lib/seasonStructure.js is missing ${name}`).not.toBeNull(); - expect(clientNorm).toBe(serverNorm); - }); - } -}); diff --git a/server/lib/shotGrammar.mirror.test.js b/server/lib/shotGrammar.mirror.test.js deleted file mode 100644 index 11bf69b819..0000000000 --- a/server/lib/shotGrammar.mirror.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; -import { compareDeclaration } from './mirrorParity.js'; - -const here = dirname(fileURLToPath(import.meta.url)); -const SERVER_COPY = join(here, 'shotGrammar.js'); -const CLIENT_COPY = join(here, '../../client/src/lib/shotGrammar.js'); - -describe('shotGrammar — server/client vocabulary parity', () => { - const server = readFileSync(SERVER_COPY, 'utf8'); - const client = readFileSync(CLIENT_COPY, 'utf8'); - - for (const name of ['SHOT_TYPES', 'SCREEN_DIRECTIONS']) { - it(`keeps ${name} identical`, () => { - const { clientDecl, serverNorm, clientNorm } = compareDeclaration(server, client, name); - expect(clientDecl, `client/src/lib/shotGrammar.js is missing ${name}`).not.toBeNull(); - expect(clientNorm).toBe(serverNorm); - }); - } -}); diff --git a/server/lib/slashdoCatalog.js b/server/lib/slashdoCatalog.js index fa83abf792..d616e1b83e 100644 --- a/server/lib/slashdoCatalog.js +++ b/server/lib/slashdoCatalog.js @@ -222,7 +222,7 @@ export function slashdoWorkflowAppliesTo(workflow, isSwiftApp) { } /** - * The workflows launchable for one app. Mirrored in + * The workflows launchable for one app. Also applied by * `client/src/lib/slashdoCatalog.js`, which drives the Agent Operations buttons; * the route uses `slashdoWorkflowAppliesTo` to reject a mismatched command rather * than trusting the client to only offer applicable ones. diff --git a/server/lib/storyBible.js b/server/lib/storyBible.js index 1aa6657715..20dec0155a 100644 --- a/server/lib/storyBible.js +++ b/server/lib/storyBible.js @@ -15,172 +15,18 @@ import { PATHS, resolveImageRef } from './fileUtils.js'; import { isPlainObject } from './objects.js'; import { shortCanonPrimaryField } from './canonPrompt.js'; import { trimTo } from './textUtils.js'; +import { BIBLE_LIMITS } from './bibleLimits.js'; // Re-export so callers (writers-room domain files) can import a single // canonical normalizer when they need to match places by slugline. export { normalizeSlugline }; export { trimTo }; -export const BIBLE_LIMITS = Object.freeze({ - NAME_MAX: 200, - ROLE_MAX: 200, - ALIAS_MAX: 100, - ALIASES_PER_ENTRY_MAX: 12, - PHYSICAL_DESCRIPTION_MAX: 2000, - PERSONALITY_MAX: 2000, - BACKGROUND_MAX: 2000, - NOTES_MAX: 4000, - IMAGE_REF_MAX: 500, - IMAGE_REFS_PER_ENTRY_MAX: 12, - // Extended character identity (novelist + graphic-novelist needs). All - // optional; sanitizer trims missing/blank to empty string. These flow into - // the bible-extraction prompt + the universe-character-expand LLM call. - PRONOUNS_MAX: 60, - AGE_MAX: 80, - CORE_THEME_MAX: 500, - SPEECH_ACCENT_MAX: 500, - // Written speech-pattern: cadence, sentence-structure, lexical tics, vocal - // habits — *not* the regional accent (that lives in SPEECH_ACCENT_MAX). - // Roomier than accent because writers tend to describe rhythm + vocabulary - // + idiom in one paragraph. - SPEECH_PATTERN_MAX: 1000, - VISUAL_NOTES_MAX: 1000, - SILHOUETTE_NOTES_MAX: 2000, - POSTURE_NOTES_MAX: 1000, - SPECIAL_TRAITS_MAX: 2000, - VISUAL_IDENTITY_MAX: 1000, - MOTIVATIONS_MAX: 2000, - // Character framework (CWQE Phase 10, #2175). The Ghost → Wound → Lie → - // Want → Need chain + Three Sliders + declared arc type. All OPTIONAL so - // every pre-existing character round-trips unchanged (absent vs empty rule). - // The checkable-test discipline (state the Lie in one sentence; Truth is its - // direct opposite; Ghost causally explains the Lie) lives in the prompt, not - // the sanitizer — these caps just bound each field's length. - GHOST_MAX: 1000, - WOUND_MAX: 1000, - LIE_MAX: 600, - WANT_MAX: 600, - NEED_MAX: 600, - // Secrets the character keeps (≥2 encouraged in the prompt). Short prose - // items, capped per-item and per-character like other string lists. - SECRET_MAX: 600, - SECRETS_PER_CHARACTER_MAX: 12, - // Three Sliders — proactivity / likability / competence on a 1–10 scale. - // Stored as integers; a value outside the range (or a non-integer) collapses - // to null (unset). Rule (prompt-enforced, not sanitizer-enforced): HIGH on ≥2, - // or HIGH on one with clear growth; all-low = boring, all-high = Mary Sue. - SLIDER_MIN: 1, - SLIDER_MAX: 10, - LIKES_MAX: 1500, - DISLIKES_MAX: 1500, - MANNERISMS_MAX: 1500, - RELATIONSHIPS_MAX: 2000, - // Structured character-to-character relationship links (#1287). The legacy - // prose `relationships` field above stays; `relationshipLinks[]` is additive. - // `description` is per-link prose; `opposition` captures a binary-tension - // axis (hunter/prey, winner/loser…) the reader watches to see reverse. - RELATIONSHIP_TARGET_ID_MAX: 64, - RELATIONSHIP_TYPE_MAX: 60, - RELATIONSHIP_DESCRIPTION_MAX: 1000, - RELATIONSHIP_OPPOSITION_AXIS_MAX: 60, - RELATIONSHIP_OPPOSITION_ROLE_MAX: 120, - RELATIONSHIP_OPPOSITION_NOTE_MAX: 600, - RELATIONSHIP_LINKS_PER_CHARACTER_MAX: 40, - SKILLS_MAX: 2000, - // Flexible stats list — open key/value so non-humans aren't forced into - // human anatomy ("Number of eyes: 8", "Form: spectral vapor", etc). - STAT_LABEL_MAX: 80, - STAT_VALUE_MAX: 200, - STATS_PER_CHARACTER_MAX: 30, - // Color palette: named hex swatches with role hints ("amber #f59e0b — skin"). - COLOR_NAME_MAX: 80, - COLOR_HEX_MAX: 10, - COLOR_ROLE_MAX: 120, - COLORS_PER_PALETTE_MAX: 12, - // Props (graphic-novelist reference): per-prop name + purpose + materials. - PROP_NAME_MAX: 120, - PROP_PURPOSE_MAX: 400, - PROP_MATERIALS_MAX: 200, - PROP_NOTES_MAX: 600, - PROPS_PER_CHARACTER_MAX: 12, - // Expressions + hand gestures: named visual cues for reference-sheet panels. - EXPRESSION_NAME_MAX: 80, - EXPRESSION_DESC_MAX: 400, - EXPRESSIONS_PER_CHARACTER_MAX: 16, - GESTURE_NAME_MAX: 80, - GESTURE_DESC_MAX: 300, - GESTURES_PER_CHARACTER_MAX: 12, - // Wardrobes per character — A2 in the AnyFilm gap analysis. Each entry - // is an outfit/styling variant; first one is the visual default. - WARDROBE_NAME_MAX: 120, - WARDROBE_DESCRIPTION_MAX: 800, - WARDROBES_PER_CHARACTER_MAX: 10, - EVIDENCE_ITEM_MAX: 500, - EVIDENCE_PER_ENTRY_MAX: 20, - // Places - SLUGLINE_MAX: 200, - PALETTE_MAX: 200, - ERA_MAX: 200, - WEATHER_MAX: 200, - RECURRING_DETAILS_MAX: 1000, - PLACE_DESCRIPTION_MAX: 2000, - // Objects - OBJECT_DESCRIPTION_MAX: 2000, - SIGNIFICANCE_MAX: 1000, - // Structured object↔character attachment links (#1288). The legacy prose - // `significance` field above stays; `attachments[]` is additive. Each link - // ties an object to ONE character and captures the emotion/significance/origin - // of that bond plus a `role` archetype. `characterId` caps match the canon id - // format; the prose fields are roomy because writers describe backstory at - // length, but tighter than NOTES so a runaway extraction stays bounded. - ATTACHMENT_CHARACTER_ID_MAX: 64, - ATTACHMENT_EMOTION_MAX: 120, - ATTACHMENT_SIGNIFICANCE_MAX: 1000, - ATTACHMENT_ORIGIN_MAX: 1000, - ATTACHMENTS_PER_OBJECT_MAX: 40, - // Per-bible cap (universal — protects against runaway extraction) - ENTRIES_PER_BIBLE_MAX: 200, - PROMPT_MAX: 2000, - TAG_MAX: 60, - TAGS_PER_ENTRY_MAX: 12, - SOURCE_SERIES_ID_MAX: 64, - // Catalog backlink: when an embedded bible entry is promoted to the - // creative-ingredients catalog (server/services/catalogDB.js), this carries - // the catalog row id so edits stay synchronized. Cap matches the catalog's - // own id format ('cat--') — generous so future id schemes fit. - INGREDIENT_ID_MAX: 64, - // Voice id namespace: `engine:voiceName` (e.g. `kokoro:af_heart`, - // `piper:en_GB-northern_english_male`). Caps generously since 3rd-party - // providers (ElevenLabs) use uuid-shaped voice ids. - VOICE_ID_MAX: 200, - // Versioned, portable voice-production intent (#5378). This records only - // creative direction and an approval decision; local profiles, providers, - // recordings, and training artifacts deliberately have no slot here. - VOICE_CANON_VERSION_MAX: 100000, - VOICE_CANON_DESCRIPTION_MAX: 1200, - VOICE_CANON_DELIVERY_MAX: 1200, - VOICE_CANON_RANGE_ITEM_MAX: 240, - VOICE_CANON_RANGE_MAX: 12, - VOICE_CANON_AVOID_ITEM_MAX: 240, - VOICE_CANON_AVOID_MAX: 12, - VOICE_CANON_PRONUNCIATION_TERM_MAX: 160, - VOICE_CANON_PRONUNCIATION_VALUE_MAX: 240, - VOICE_CANON_PRONUNCIATIONS_MAX: 24, - // Approved identity-pack assets are a curated view over imageRefs[], not a - // second image store. Only an existing managed reference can be assigned. - IDENTITY_PACK_ASSETS_MAX: 24, - IDENTITY_PACK_AVOID_ITEM_MAX: 240, - IDENTITY_PACK_AVOID_MAX: 12, - // Reveal-gated canon (#2178): `surfaceDescriptor` is the pre-reveal - // stand-in — what the world looks like BEFORE the spoiler is due ("the - // locked east wing" vs "the wing where the heir is imprisoned"). Roomy - // like a place description so a full surface-level paragraph fits. - SURFACE_DESCRIPTOR_MAX: 2000, - // Upper bound for the issue number a canon fact is revealed in. A generous - // cap that comfortably exceeds any real series length while still rejecting - // a hallucinated/overflowed integer. - REVEAL_ISSUE_MAX: 100000, -}); +// The canon field caps live in a pure leaf (`bibleLimits.js`) so the browser +// bundle and `catalogTypes.js` can read them without this module's `crypto` / +// `fileUtils` imports. Re-exported here because every sanitizer caller reaches +// for `BIBLE_LIMITS` through `storyBible.js`. +export { BIBLE_LIMITS }; // Portable production posture only. Performer identity, contracts, source // recordings, provider ids, and local artifact paths must never enter a diff --git a/server/lib/textUtils.js b/server/lib/textUtils.js index 773df9246a..f0803cf57d 100644 --- a/server/lib/textUtils.js +++ b/server/lib/textUtils.js @@ -4,8 +4,8 @@ // regexes (`server/services/writersRoom/local.js`, `server/lib/issueLength.js`, // and the client's `client/src/utils/formatters.js`). They all converge on the // same intent — count whitespace-delimited tokens — so this is the canonical -// server-side home. The client copy (which cannot import from `server/`) mirrors -// this exact semantics so client and server word counts always agree. +// server-side home. `client/src/lib/textUtils.js` re-exports `escapeRegExp` from +// here, so keep this module pure: no Node built-in, nothing outside `server/lib`. /** * Count whitespace-separated words in a string. diff --git a/server/lib/textUtils.test.js b/server/lib/textUtils.test.js index db96cdf945..b8a21e9211 100644 --- a/server/lib/textUtils.test.js +++ b/server/lib/textUtils.test.js @@ -1,10 +1,6 @@ import { describe, it, expect } from 'vitest'; import { clampToCharLimit, countWords, escapeRegExp, trimTo } from './textUtils.js'; -import { readFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; import { collectClientSources, collectServerSources, readClientSource, readServerSource } from './testHelper.js'; -import { compareDeclaration } from './mirrorParity.js'; describe('countWords', () => { it('counts whitespace-separated tokens', () => { @@ -123,12 +119,6 @@ const ESCAPE_IDIOMS = [ /(?:^|[^\w$.])(?:const|let|var|function)\s+escapeRegExp\b/, ]; -// The client mirror. It is the ONE file on that side allowed to spell the escape, -// exactly as `lib/textUtils.js` is on this one — every other client caller imports -// it. There is no third exemption, and the scenePrompt holdout that used to sit -// here is gone: the client mirror is what let `lib/scenePrompt.js` migrate (#5790). -const CLIENT_OWNER = 'lib/textUtils.js'; - const escapeIdiomCount = (source) => ESCAPE_IDIOMS .map((idiom) => source.match(new RegExp(idiom.source, 'g'))?.length ?? 0) .reduce((most, count) => Math.max(most, count), 0); @@ -144,13 +134,14 @@ describe('no private escapeRegExp', () => { ).toEqual([]); }); - // The client half of the same guard. The browser cannot import `server/lib`, so - // for as long as this side had no home for the escape every new client caller - // copied the nearest one — five product modules and a test had done so by #5790. - // `collectClientSources` counts `.jsx` and client TESTS too; see its docstring. - it('leaves client/src/lib/textUtils.js as the only RegExp-escape implementation under client/src/', () => { + // The client half of the same guard, and it now allows NO exemption: since #6364 + // `client/src/lib/textUtils.js` re-exports this module rather than copying it, so + // no file under `client/src/` spells the escape at all. Before that, every new + // client caller copied the nearest one — five product modules and a test had done + // so by #5790. `collectClientSources` counts `.jsx` and client TESTS too; see its + // docstring. + it('leaves no RegExp-escape implementation anywhere under client/src/', () => { const offenders = collectClientSources() - .filter((rel) => rel !== CLIENT_OWNER) .filter((rel) => escapeIdiomCount(readClientSource(rel)) > 0); expect( offenders, @@ -162,24 +153,11 @@ describe('no private escapeRegExp', () => { // offender list is equally what a walk returning nothing produces. it('detects a re-inlined copy under any of its spellings', () => { expect(escapeIdiomCount(readServerSource('lib/textUtils.js'))).toBeGreaterThan(0); - expect(escapeIdiomCount(readClientSource(CLIENT_OWNER))).toBeGreaterThan(0); + // The client walk has no exempt file left to prove itself against, so pin it + // on a synthetic copy instead — an empty offender list must mean "nobody + // spells it", not "the walk read nothing". + expect(collectClientSources().length).toBeGreaterThan(100); + expect(escapeIdiomCount("const escapeRegExp = (s) => s;")).toBeGreaterThan(0); expect(escapeIdiomCount('const x = 1;')).toBe(0); }); }); - -// The client copy is a declared mirror (`client/src/lib/README.md`), so -// `mirrorCoverage.test.js` requires a test that reads BOTH files — this is it. -// It is a PARTIAL mirror: only `escapeRegExp` crosses, because it is the only -// member the bundle has a caller for. -describe('escapeRegExp — server/client mirror parity', () => { - const here = dirname(fileURLToPath(import.meta.url)); - const CLIENT_COPY = join(here, '../../client/src/lib/textUtils.js'); - - it('keeps escapeRegExp identical', () => { - const server = readFileSync(join(here, 'textUtils.js'), 'utf8'); - const client = readFileSync(CLIENT_COPY, 'utf8'); - const { clientDecl, serverNorm, clientNorm } = compareDeclaration(server, client, 'escapeRegExp'); - expect(clientDecl, 'client/src/lib/textUtils.js is missing escapeRegExp').not.toBeNull(); - expect(clientNorm).toBe(serverNorm); - }); -}); diff --git a/server/lib/tribeCadence.js b/server/lib/tribeCadence.js index 2a81397590..9b867c31e0 100644 --- a/server/lib/tribeCadence.js +++ b/server/lib/tribeCadence.js @@ -1,11 +1,10 @@ // Authoritative, pure cadence rules for the Tribe care system — the single // source of truth for "who needs care." Consumed on the server by // `personCadenceStatus` / `getCareSummary` (server/services/tribe.js → the -// proactive-alerts check + the Tribe Care dashboard widget) and mirrored to +// proactive-alerts check + the Tribe Care dashboard widget) and re-exported by // `client/src/lib/tribeCadence.js` for the client bundle (Tribe page + circle -// map). The mirror must produce IDENTICAL output; the cross-boundary contract -// test (client/src/lib/tribeCadence.contract.test.js) imports both copies and -// asserts they never drift. No Node-only deps — keep this file pure. +// map), so both sides run this code rather than two copies of it. Keep it pure: +// no Node built-in, nothing outside `server/lib`. // The four inner rings owe a care cadence; `external` (former contacts, a // nemesis) is outside the tribe and is never nagged. diff --git a/server/lib/tribeCadence.test.js b/server/lib/tribeCadence.test.js new file mode 100644 index 0000000000..f614e95c4d --- /dev/null +++ b/server/lib/tribeCadence.test.js @@ -0,0 +1,62 @@ +/** + * Cadence rules for Tribe care (#2032/#2060). + * + * These pin the SEMANTICS, not a copy: `client/src/lib/tribeCadence.js` + * re-exports this module, so the Tribe page, the circle map, the proactive-alerts + * check and the Care dashboard widget all run exactly this code. + */ +import { describe, it, expect } from 'vitest'; +import { cadenceStatus, daysSinceDate, DEFAULT_CADENCE_DAYS, SOON_WINDOW_DAYS } from './tribeCadence.js'; + +// N days before today as a YYYY-MM-DD string, so the suite is date-independent. +// Built from LOCAL calendar fields — `daysSinceDate` parses the `YYYY-MM-DD` in +// local time, so `toISOString()` (UTC) would shift the date across the day +// boundary in the evening and make the elapsed-day math off-by-one. +function daysAgo(n) { + const d = new Date(); + d.setDate(d.getDate() - n); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +describe('tribeCadence — tuning constants', () => { + it('exposes the defaults every surface reads', () => { + expect(DEFAULT_CADENCE_DAYS).toBeGreaterThan(0); + expect(SOON_WINDOW_DAYS).toBe(7); + }); +}); + +describe('tribeCadence — daysSinceDate', () => { + it('returns null for anything that is not a parsable date', () => { + for (const value of [null, undefined, '', 'garbage']) { + expect(daysSinceDate(value)).toBeNull(); + } + }); + + it('counts elapsed local days', () => { + expect(daysSinceDate(daysAgo(0))).toBe(0); + expect(daysSinceDate(daysAgo(3))).toBe(3); + }); +}); + +describe('tribeCadence — cadence rules', () => { + it('external members are excluded from care (never nagged)', () => { + expect(cadenceStatus({ ring: 'external', lastContact: daysAgo(999), cadenceDays: 7 })) + .toEqual({ state: 'external', daysRemaining: null, daysOverdue: 0 }); + }); + + it('distinguishes missing (never contacted) from overdue', () => { + const missing = cadenceStatus({ ring: 'core', lastContact: null, cadenceDays: 21 }); + expect(missing.state).toBe('missing'); + expect(missing.daysRemaining).toBeNull(); + expect(missing.daysOverdue).toBeNull(); // missing sorts above dated-overdue + + const overdue = cadenceStatus({ ring: 'support', lastContact: daysAgo(10), cadenceDays: 7 }); + expect(overdue.state).toBe('overdue'); + expect(overdue.daysOverdue).toBe(3); + }); + + it('treats <=7 days remaining as soon, >7 as steady', () => { + expect(cadenceStatus({ ring: 'core', lastContact: daysAgo(14), cadenceDays: 21 }).state).toBe('soon'); + expect(cadenceStatus({ ring: 'core', lastContact: daysAgo(13), cadenceDays: 21 }).state).toBe('steady'); + }); +}); diff --git a/server/lib/videoReferenceModes.js b/server/lib/videoReferenceModes.js index e46190ec3c..31733f6b78 100644 --- a/server/lib/videoReferenceModes.js +++ b/server/lib/videoReferenceModes.js @@ -20,9 +20,9 @@ * boundary rejects instead (`i2vReferenceModeViolation` below), and the LTX * helper fails loudly rather than downgrading mid-render. * - * PURE and MIRRORED to `client/src/lib/videoReferenceModes.js` (byte-for-byte; - * `videoReferenceModes.mirror.test.js` is the contract) — so it must not import - * `ServerError` or anything else server-side. Callers translate the returned + * A pure leaf re-exported by `client/src/lib/videoReferenceModes.js` — so it + * must not import `ServerError`, any Node built-in, or anything outside + * `server/lib`. Callers translate the returned * `{ code, message }` into whatever their layer throws: * `videoReferenceModeError()` in services/videoGen/modeContract.js does that * for the route + render boundaries. diff --git a/server/lib/videoReferenceModes.mirror.test.js b/server/lib/videoReferenceModes.mirror.test.js deleted file mode 100644 index a5aaa04a1f..0000000000 --- a/server/lib/videoReferenceModes.mirror.test.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Mirror parity test for server/lib/videoReferenceModes.js ↔ - * client/src/lib/videoReferenceModes.js - * - * The server decides whether a reference mode is honorable; the client only - * previews that decision (which options to offer, what promise to print, what - * conditioning strength will actually apply). A drifted client offers Inspire - * on a runtime that pins frame one — the exact silent downgrade this contract - * exists to prevent. - * - * Comparison strips comments, so the intentionally divergent header commentary - * does not fail the test — only logic does. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { compareDeclaration } from './mirrorParity.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const SERVER_PATH = resolve(__dirname, 'videoReferenceModes.js'); -const CLIENT_PATH = resolve(__dirname, '../../client/src/lib/videoReferenceModes.js'); - -const MIRRORED_NAMES = [ - 'I2V_REFERENCE_MODES', - 'DEFAULT_I2V_REFERENCE_MODE', - 'I2V_REFERENCE_MODE_OPTIONS', - 'I2V_REFERENCE_MODE_RUNTIMES', - 'INSPIRE_DEFAULT_IMAGE_STRENGTH', - 'normalizeI2vReferenceMode', - 'isDefaultI2vReferenceMode', - 'isKnownI2vReferenceMode', - 'runtimeSupportsI2vReferenceMode', - 'i2vReferenceModeLabel', - 'resolveI2vReferenceStrength', - 'i2vReferenceModeViolation', -]; - -describe('videoReferenceModes server↔client mirror parity', () => { - const serverSrc = readFileSync(SERVER_PATH, 'utf8'); - const clientSrc = readFileSync(CLIENT_PATH, 'utf8'); - - it('both files are non-empty', () => { - expect(serverSrc.length).toBeGreaterThan(100); - expect(clientSrc.length).toBeGreaterThan(100); - }); - - for (const name of MIRRORED_NAMES) { - it(`${name} is present and identical on both sides (code only)`, () => { - const { serverDecl, clientDecl, serverNorm, clientNorm } = - compareDeclaration(serverSrc, clientSrc, name); - - expect(serverDecl, `server/lib/videoReferenceModes.js is missing: ${name}`).not.toBeNull(); - expect(clientDecl, `client/src/lib/videoReferenceModes.js is missing: ${name}`).not.toBeNull(); - expect( - clientNorm, - `"${name}" diverged — the server copy is authoritative; port the change verbatim`, - ).toBe(serverNorm); - }); - } -}); diff --git a/server/lib/youtubeUrl.js b/server/lib/youtubeUrl.js index 23b0d26f03..7dbe620df2 100644 --- a/server/lib/youtubeUrl.js +++ b/server/lib/youtubeUrl.js @@ -12,15 +12,15 @@ * `400 YOUTUBE_URL_INVALID` even though yt-dlp handles it and the other two * pipelines accepted it. * - * `client/src/lib/youtubeUrl.js` is the browser mirror of this rule (Quick - * Capture swaps its whole submit path on it); `youtubeUrl.mirror.test.js` - * asserts the two agree on behavior, so port any change there verbatim. + * A pure leaf: `client/src/lib/youtubeUrl.js` re-exports it (Quick Capture swaps + * its whole submit path on the predicate), so this module must import no Node + * built-in and nothing outside `server/lib`. The throwing form lives in + * `youtubeUrlAssert.js` for that reason. * * Deliberately narrow: playlists, channels, and `/@handle` pages are NOT * matched — a paste that would have yt-dlp pull 300 videos must fail fast * rather than silently start a batch download. */ -import { ServerError } from './errorHandler.js'; /** * Accepts every URL shape that carries exactly one video id, across the @@ -49,7 +49,7 @@ export function youtubeVideoIdFromUrl(url) { return pathId ? pathId[1] : null; } -/** Alias matching the client mirror's naming, so both layers read alike. */ +/** Alias kept for the call sites (and the client re-export) that read better with it. */ export const youtubeVideoId = youtubeVideoIdFromUrl; /** True when `url` is a single-video YouTube URL the server will accept. */ @@ -60,16 +60,3 @@ export function isYoutubeVideoUrl(url) { /** Shared rejection copy, so the Zod schemas and the services name one rule. */ export const YOUTUBE_URL_INVALID_MESSAGE = 'Expected a single-video YouTube URL (watch, shorts, live, embed, music.youtube.com, or youtu.be) — playlists and channels are not supported'; - -/** - * Validate a URL and hand back the video id it carries — the id has to be - * parsed to validate at all, so returning it keeps the caller from parsing the - * same URL a second time (and from disagreeing about the answer). - */ -export function assertYoutubeVideoUrl(url) { - const videoId = isYoutubeVideoUrl(url) ? youtubeVideoIdFromUrl(url) : null; - if (!videoId) { - throw new ServerError(YOUTUBE_URL_INVALID_MESSAGE, { status: 400, code: 'YOUTUBE_URL_INVALID' }); - } - return videoId; -} diff --git a/server/lib/youtubeUrl.mirror.test.js b/server/lib/youtubeUrl.mirror.test.js deleted file mode 100644 index f46067a808..0000000000 --- a/server/lib/youtubeUrl.mirror.test.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Mirror parity test for the YouTube single-video URL rule. - * - * Authoritative: `isYoutubeVideoUrl` + `youtubeVideoIdFromUrl` (`server/lib/youtubeUrl.js`). - * Mirror: `isYoutubeVideoUrl` + `youtubeVideoId` (client). - * - * Quick Capture swaps its ENTIRE submit path on the client predicate — a looser - * client offers ingest options for a URL the server will reject with a 400, and - * a tighter one silently files a real video as a plain link. Unlike the - * bareUrl mirror (which compares declaration source text), this compares - * BEHAVIOR: the two live in differently-shaped modules — the client copy also - * carries the ingest-options table — so a text diff would fail on structure - * rather than on drift. - */ - -import { describe, it, expect } from 'vitest'; -import { isYoutubeVideoUrl as serverAccepts, youtubeVideoIdFromUrl } from './youtubeUrl.js'; -import { isYoutubeVideoUrl, youtubeVideoId } from '../../client/src/lib/youtubeUrl.js'; - -const CASES = [ - 'https://youtu.be/oCnxnaVg0bY', - 'http://youtu.be/oCnxnaVg0bY', - 'https://www.youtube.com/watch?v=oCnxnaVg0bY', - 'https://www.youtube.com/watch?v=oCnxnaVg0bY&list=PLabc&index=2', - 'https://m.youtube.com/watch?v=oCnxnaVg0bY&t=42s', - 'https://music.youtube.com/watch?v=oCnxnaVg0bY', - 'https://youtube.com/shorts/oCnxnaVg0bY', - 'https://www.youtube.com/live/oCnxnaVg0bY', - 'https://www.youtube.com/embed/oCnxnaVg0bY', - // Not single videos — must be rejected by both. - 'https://www.youtube.com/playlist?list=PLabcdefghij', - 'https://www.youtube.com/@somechannel', - 'https://www.youtube.com/c/somechannel', - 'https://www.youtube.com/feed/history', - 'https://vimeo.com/123456789', - 'https://example.com/watch?v=oCnxnaVg0bY', - 'not a url', - '', -]; - -describe('youtubeUrl server↔client mirror parity', () => { - it.each(CASES)('agrees on whether %j is a single-video YouTube URL', (url) => { - expect(isYoutubeVideoUrl(url)).toBe(serverAccepts(url)); - }); - - it.each(CASES.filter(serverAccepts))('extracts the same video id from %j', (url) => { - expect(youtubeVideoId(url)).toBe(youtubeVideoIdFromUrl(url)); - }); - - it('covers both outcomes, so a rule that always returns one value cannot pass', () => { - expect(CASES.some(serverAccepts)).toBe(true); - expect(CASES.some((u) => !serverAccepts(u))).toBe(true); - }); -}); diff --git a/server/lib/youtubeUrl.test.js b/server/lib/youtubeUrl.test.js new file mode 100644 index 0000000000..4af6895e89 --- /dev/null +++ b/server/lib/youtubeUrl.test.js @@ -0,0 +1,70 @@ +/** + * The canonical YouTube single-video URL rule (#6014). + * + * Quick Capture swaps its ENTIRE submit path on `isYoutubeVideoUrl` — it + * re-exports this module, so a URL the box offers ingest options for is exactly + * one the routes accept. The matrix below is what "single video" means: + * every shape YouTube emits, and the playlist/channel/feed shapes that must be + * refused so a paste cannot silently start a 300-video batch download. + */ +import { describe, it, expect } from 'vitest'; +import { isYoutubeVideoUrl, youtubeVideoId, youtubeVideoIdFromUrl } from './youtubeUrl.js'; + +const ACCEPTED = [ + 'https://youtu.be/oCnxnaVg0bY', + 'http://youtu.be/oCnxnaVg0bY', + 'https://www.youtube.com/watch?v=oCnxnaVg0bY', + 'https://www.youtube.com/watch?v=oCnxnaVg0bY&list=PLabc&index=2', + 'https://m.youtube.com/watch?v=oCnxnaVg0bY&t=42s', + 'https://music.youtube.com/watch?v=oCnxnaVg0bY', + 'https://youtube.com/shorts/oCnxnaVg0bY', + 'https://www.youtube.com/live/oCnxnaVg0bY', + 'https://www.youtube.com/embed/oCnxnaVg0bY', +]; + +const REFUSED = [ + 'https://www.youtube.com/playlist?list=PLabcdefghij', + 'https://www.youtube.com/@somechannel', + 'https://www.youtube.com/c/somechannel', + 'https://www.youtube.com/feed/history', + 'https://vimeo.com/123456789', + 'https://example.com/watch?v=oCnxnaVg0bY', + 'not a url', + '', +]; + +describe('isYoutubeVideoUrl', () => { + it.each(ACCEPTED)('accepts %j', (url) => { + expect(isYoutubeVideoUrl(url)).toBe(true); + }); + + it.each(REFUSED)('refuses %j', (url) => { + expect(isYoutubeVideoUrl(url)).toBe(false); + }); + + it('refuses a non-string without throwing', () => { + for (const value of [null, undefined, 42, {}]) { + expect(isYoutubeVideoUrl(value)).toBe(false); + } + }); +}); + +describe('youtubeVideoIdFromUrl', () => { + it.each(ACCEPTED)('extracts the one video id from %j', (url) => { + expect(youtubeVideoIdFromUrl(url)).toBe('oCnxnaVg0bY'); + }); + + it('is what the `youtubeVideoId` alias resolves to', () => { + expect(youtubeVideoId).toBe(youtubeVideoIdFromUrl); + }); + + it('returns null when there is no id to read', () => { + expect(youtubeVideoIdFromUrl('https://www.youtube.com/@somechannel')).toBeNull(); + expect(youtubeVideoIdFromUrl('')).toBeNull(); + expect(youtubeVideoIdFromUrl(null)).toBeNull(); + }); + + it('bounds the charset so a garbage query string cannot smuggle a giant id', () => { + expect(youtubeVideoIdFromUrl(`https://www.youtube.com/watch?v=${'a'.repeat(64)}`)).toHaveLength(20); + }); +}); diff --git a/server/lib/youtubeUrlAssert.js b/server/lib/youtubeUrlAssert.js new file mode 100644 index 0000000000..e62ac00563 --- /dev/null +++ b/server/lib/youtubeUrlAssert.js @@ -0,0 +1,23 @@ +/** + * The throwing form of the canonical YouTube single-video URL rule. + * + * Split out of `youtubeUrl.js` so that module stays a pure leaf the browser + * bundle can import (`client/src/lib/youtubeUrl.js` re-exports it): + * `ServerError` reaches for Node's `events`, which has no place in the client + * build. Route/service callers that want the 400 keep importing this wrapper. + */ +import { ServerError } from './errorHandler.js'; +import { isYoutubeVideoUrl, youtubeVideoIdFromUrl, YOUTUBE_URL_INVALID_MESSAGE } from './youtubeUrl.js'; + +/** + * Validate a URL and hand back the video id it carries — the id has to be + * parsed to validate at all, so returning it keeps the caller from parsing the + * same URL a second time (and from disagreeing about the answer). + */ +export function assertYoutubeVideoUrl(url) { + const videoId = isYoutubeVideoUrl(url) ? youtubeVideoIdFromUrl(url) : null; + if (!videoId) { + throw new ServerError(YOUTUBE_URL_INVALID_MESSAGE, { status: 400, code: 'YOUTUBE_URL_INVALID' }); + } + return videoId; +} diff --git a/server/routes/cosStatusRoutes.js b/server/routes/cosStatusRoutes.js index cce050c4f7..b848561294 100644 --- a/server/routes/cosStatusRoutes.js +++ b/server/routes/cosStatusRoutes.js @@ -12,10 +12,9 @@ import { validateRequest } from '../lib/validation.js'; import { z } from 'zod'; import { DOMAIN_IDS, DOMAIN_MODES } from '../lib/domainAutonomy.js'; import { AVATAR_VARIANT_PATTERN, RIGGED_VARIANT_PREFIX } from '../lib/avatarVariants.js'; -// Single source of truth for the avatar-style vocabulary (#6253) — a -// dependency-free leaf, safe to import from the server the way -// `server/lib/personaTraitBlend.js` imports `clamp` from client `utils/formatters.js`. -import { AVATAR_STYLE_IDS } from '../../client/src/lib/avatarStyles.js'; +// Single source of truth for the avatar-style vocabulary (#6253); the client +// picker re-exports the same leaf. +import { AVATAR_STYLE_IDS } from '../lib/avatarStyles.js'; import { BUDGET_LIMIT_FIELDS } from '../lib/domainBudgets.js'; import { persistentMindCapabilitiesSchema } from '../lib/persistentMindCapabilities.js'; import { persistentMindProfileSchema } from '../lib/persistentMindProfile.js'; diff --git a/server/routes/cosStatusRoutesAvatar.test.js b/server/routes/cosStatusRoutesAvatar.test.js index 5e19f522c4..8f6a34f440 100644 --- a/server/routes/cosStatusRoutesAvatar.test.js +++ b/server/routes/cosStatusRoutesAvatar.test.js @@ -10,7 +10,7 @@ vi.mock('../services/taskWatcher.js', () => ({})); vi.mock('../services/memoryEmbeddings.js', () => ({ reinitialize: vi.fn() })); import { cosConfigSchema } from './cosStatusRoutes.js'; -import { AVATAR_STYLE_IDS } from '../../client/src/lib/avatarStyles.js'; +import { AVATAR_STYLE_IDS } from '../lib/avatarStyles.js'; describe('cosConfigSchema avatarStyle', () => { it('accepts every style in the shared registry, so a style added there is never a settings 400', () => { diff --git a/server/services/creativeDirectorPrompts.js b/server/services/creativeDirectorPrompts.js index 0c1abae0b1..a473bcae65 100644 --- a/server/services/creativeDirectorPrompts.js +++ b/server/services/creativeDirectorPrompts.js @@ -24,7 +24,7 @@ import { QUALITY_PRESETS, presetToRenderParams, } from '../lib/creativeDirectorPresets.js'; -import { PORTOS_API_URL } from '../lib/ports.js'; +import { PORTOS_API_URL } from '../lib/portosUrls.js'; import { buildPrompt } from './promptService.js'; // Shared project-block view used by both prompt stages. Defaults out diff --git a/server/services/taskPromptDefaults.test.js b/server/services/taskPromptDefaults.test.js index 03a79035d6..08a9bb0e24 100644 --- a/server/services/taskPromptDefaults.test.js +++ b/server/services/taskPromptDefaults.test.js @@ -140,7 +140,7 @@ describe('taskPromptDefaults integrity snapshot', () => { const [freshDefaults, { PORTOS_API_URL }] = await Promise.all([ import('./taskPromptDefaults.js'), - import('../lib/ports.js'), + import('../lib/portosUrls.js'), ]); // Guard the guard: if the stub stopped taking effect this case would pass // vacuously by re-running the ambient-environment assertions above. diff --git a/server/services/taskPromptDefaults/integrityHash.js b/server/services/taskPromptDefaults/integrityHash.js index f62f457ab6..a3e36b6ecf 100644 --- a/server/services/taskPromptDefaults/integrityHash.js +++ b/server/services/taskPromptDefaults/integrityHash.js @@ -9,7 +9,7 @@ * pure data leaf, and this is tooling for the snapshot rather than prompt data. */ import { createHash } from 'crypto'; -import { PORTOS_API_URL } from '../../lib/ports.js'; +import { PORTOS_API_URL } from '../../lib/portosUrls.js'; const API_URL_PLACEHOLDER = '{{PORTOS_API_URL}}'; diff --git a/server/services/taskPromptDefaults/previousDefaults.js b/server/services/taskPromptDefaults/previousDefaults.js index cd9336992f..f597f9ce1f 100644 --- a/server/services/taskPromptDefaults/previousDefaults.js +++ b/server/services/taskPromptDefaults/previousDefaults.js @@ -9,7 +9,7 @@ // PORTOS_API_URL is interpolated into the claim-issue-jira previous default below, // mirroring how prompts.js renders the current default (so a stored jira prompt on // this install resolves to the same string for auto-upgrade recognition). -import { PORTOS_API_URL } from '../../lib/ports.js'; +import { PORTOS_API_URL } from '../../lib/portosUrls.js'; // Known previous default prompts for legacy migration. // When a schedule has no promptVersion, we check if the stored prompt matches diff --git a/server/services/taskPromptDefaults/prompts.js b/server/services/taskPromptDefaults/prompts.js index 94e19de49b..f2158e5430 100644 --- a/server/services/taskPromptDefaults/prompts.js +++ b/server/services/taskPromptDefaults/prompts.js @@ -10,7 +10,7 @@ */ // PORTOS_API_URL is interpolated into the jira-status-report default prompt below. -import { PORTOS_API_URL } from '../../lib/ports.js'; +import { PORTOS_API_URL } from '../../lib/portosUrls.js'; import { DISPATCH_HINT_FANOUT_GUIDANCE, EPIC_DECOMPOSED_LABEL, diff --git a/server/services/taskSchedule.test.js b/server/services/taskSchedule.test.js index 974ebb8abe..94f1cbd505 100644 --- a/server/services/taskSchedule.test.js +++ b/server/services/taskSchedule.test.js @@ -74,7 +74,7 @@ vi.mock('./instanceFeatures.js', () => ({ isInstanceFeatureEnabled: vi.fn().mockResolvedValue(true), })) -vi.mock('../lib/ports.js', () => ({ +vi.mock('../lib/portosUrls.js', () => ({ PORTOS_UI_URL: 'http://localhost:5554', PORTOS_API_URL: 'http://localhost:5555' })) diff --git a/server/services/trackYoutubeImport.js b/server/services/trackYoutubeImport.js index 221bef4ffd..6282e42b2c 100644 --- a/server/services/trackYoutubeImport.js +++ b/server/services/trackYoutubeImport.js @@ -13,7 +13,8 @@ import { randomUUID } from 'crypto'; import { join } from 'path'; -import { assertYoutubeVideoUrl, YOUTUBE_VIDEO_URL_RE } from '../lib/youtubeUrl.js'; +import { YOUTUBE_VIDEO_URL_RE } from '../lib/youtubeUrl.js'; +import { assertYoutubeVideoUrl } from '../lib/youtubeUrlAssert.js'; import { shortId, PATHS } from '../lib/fileUtils.js'; import { probeVideoDuration } from '../lib/ffmpeg.js'; import { broadcastSse, attachSseClient as attachSse, closeJobAfterDelay } from '../lib/sseUtils.js'; diff --git a/server/services/youtubeIngest.js b/server/services/youtubeIngest.js index 29f30fb7bc..db651d58f6 100644 --- a/server/services/youtubeIngest.js +++ b/server/services/youtubeIngest.js @@ -52,7 +52,8 @@ import { vttToPlainText } from '../lib/vttTranscript.js'; import { createMutex } from '../lib/asyncMutex.js'; import { downloadAudioToTempMp3 } from './ytdlpAudioImport.js'; import { downloadVideoIntoLibrary } from './videoDownload.js'; -import { assertYoutubeVideoUrl, YOUTUBE_VIDEO_URL_RE } from '../lib/youtubeUrl.js'; +import { YOUTUBE_VIDEO_URL_RE } from '../lib/youtubeUrl.js'; +import { assertYoutubeVideoUrl } from '../lib/youtubeUrlAssert.js'; // The pure half of the ingest — yt-dlp metadata normalization, the Obsidian // note body, the CoS agent prompt, and the index's Obsidian-pointer rule — lives // in lib/ so it is unit-testable without this module's spawn/store graph (#6015).