diff --git a/client/src/components/CmdKSearch.jsx b/client/src/components/CmdKSearch.jsx
index 41f4f2f50e..0aa41b56d3 100644
--- a/client/src/components/CmdKSearch.jsx
+++ b/client/src/components/CmdKSearch.jsx
@@ -14,6 +14,7 @@ import { recordManualLayoutPick } from '../utils/timeWindow.js';
import { RECENT_KEY, RECENT_CAP, resolveRecentNavEntries } from '../utils/navWorkingSet.js';
import { filterNavByFeatures } from '../lib/navFeatures.js';
import { safeReadJsonStorage } from '../lib/safeStorage.js';
+import { escapeRegExp } from '../lib/textUtils.js';
const ICON_MAP = { Brain, Cpu, Package, History, HeartPulse };
@@ -46,7 +47,7 @@ const precompute = (cmd) => ({
function Highlight({ text, query }) {
if (!query || !text) return {text};
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const escaped = escapeRegExp(query);
const parts = text.split(new RegExp(`(${escaped})`, 'gi'));
return (
diff --git a/client/src/hooks/mountedRefConventions.test.js b/client/src/hooks/mountedRefConventions.test.js
index 4ce5475929..308026fa52 100644
--- a/client/src/hooks/mountedRefConventions.test.js
+++ b/client/src/hooks/mountedRefConventions.test.js
@@ -77,6 +77,7 @@ import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { trackedSourceFiles } from '../test/trackedFiles.js';
+import { escapeRegExp } from '../lib/textUtils.js';
const CLIENT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -88,14 +89,12 @@ const HOOK_FILE = 'src/hooks/useMounted.js';
// correct) pattern, so it is intentionally out of scope.
const TRUE_SEEDED_REF = /const\s+([A-Za-z_$][\w$]*)\s*=\s*useRef\(\s*true\s*\)/g;
-// Ref names may legally contain `$`, which is a regex anchor — interpolating one
-// raw would silently make the pattern unmatchable and pass the offender.
-const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-
// `=` and `||=` both count as re-arming. Matching only `=` would report a ref
// re-armed with `ref.current ||= true` as broken — a false positive that would
-// push someone to "fix" already-correct code.
-const assignsRe = (name, value) => new RegExp(`\\b${escapeRe(name)}\\.current\\s*(?:\\|\\|)?=\\s*${value}\\b`);
+// push someone to "fix" already-correct code. The name is escaped because ref
+// names may legally contain `$`, which is a regex anchor — interpolating one raw
+// would silently make the pattern unmatchable and pass the offender.
+const assignsRe = (name, value) => new RegExp(`\\b${escapeRegExp(name)}\\.current\\s*(?:\\|\\|)?=\\s*${value}\\b`);
const USE_EFFECT_OPEN = /\buseEffect\s*\(/g;
diff --git a/client/src/lib/README.md b/client/src/lib/README.md
index 96028b85a0..36af324278 100644
--- a/client/src/lib/README.md
+++ b/client/src/lib/README.md
@@ -205,6 +205,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. |
| `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/editorialChecks.js b/client/src/lib/editorialChecks.js
index 8a28783239..dad148f8e2 100644
--- a/client/src/lib/editorialChecks.js
+++ b/client/src/lib/editorialChecks.js
@@ -2,6 +2,7 @@
// findings triage grouping, and manuscript deep-link building. No React, no
// window: the page component and its unit tests both consume these.
import { descriptorForCanonEntry } from './canonPrompt.js';
+import { escapeRegExp } from './textUtils.js';
// Scope display order + labels (mirrors server CHECK_SCOPES). A check whose
// scope isn't one of these still renders, bucketed under its raw scope last.
@@ -472,8 +473,6 @@ export function canonEntitiesFromUniverse(universe) {
return out;
}
-const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-
// Build a single case-insensitive, word-boundary-anchored matcher over every
// canon name (longest first so "Jon Snow" wins over "Jon"), plus a lowercase
// name→entity map. Returns `null` when there are no usable names.
diff --git a/client/src/lib/index.js b/client/src/lib/index.js
index 161b98a5ee..de1d2f45ab 100644
--- a/client/src/lib/index.js
+++ b/client/src/lib/index.js
@@ -172,6 +172,7 @@ export * from './tailnetPeer.js';
export * from './terminalDictation.js';
export * from './terminalScroll.js';
export * from './terminalTheme.js';
+export * from './textUtils.js';
export * from './threejsAnimation.js';
export * from './threejsEnvironment.js';
export * from './threejsExplode.js';
diff --git a/client/src/lib/loraTriggers.js b/client/src/lib/loraTriggers.js
index 4f5f336221..d04940a524 100644
--- a/client/src/lib/loraTriggers.js
+++ b/client/src/lib/loraTriggers.js
@@ -9,6 +9,8 @@
* identical to the server module or the picker's hint will contradict the render.
*/
+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) => {
@@ -17,8 +19,6 @@ export const firstTriggerWord = (words) => {
return first ? first.trim() : null;
};
-const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-
// 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
diff --git a/client/src/lib/manuscriptAnchors.js b/client/src/lib/manuscriptAnchors.js
index de0b0bea96..6a45755a91 100644
--- a/client/src/lib/manuscriptAnchors.js
+++ b/client/src/lib/manuscriptAnchors.js
@@ -18,6 +18,8 @@
* carrying every comment covering it, toned by the highest severity present.
*/
+import { escapeRegExp } from './textUtils.js';
+
const SEVERITY_RANK = { high: 3, medium: 2, low: 1 };
// Locate the `find` span to highlight/replace. `indexOf` alone targets the FIRST
@@ -49,7 +51,7 @@ export function locateFindSpan(text, find, anchorQuote) {
const exact = locateFind(text, find, anchorQuote);
if (exact !== -1) return { start: exact, end: exact + find.length };
- const escaped = find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const escaped = escapeRegExp(find);
const re = new RegExp(escaped.replace(/\s+/g, '\\s+'), 'g');
const anchorIdx = anchorQuote ? text.indexOf(anchorQuote) : -1;
let best = null;
diff --git a/client/src/lib/scenePrompt.js b/client/src/lib/scenePrompt.js
index 5db0774b49..d205cc1674 100644
--- a/client/src/lib/scenePrompt.js
+++ b/client/src/lib/scenePrompt.js
@@ -2,6 +2,7 @@
// 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;
@@ -63,7 +64,7 @@ function matchEntriesByCandidates(text, entries, candidatesFn) {
const seen = new Set();
const wordBoundary = (needle) => {
if (!needle) return false;
- const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ 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}_]`
diff --git a/client/src/lib/textUtils.js b/client/src/lib/textUtils.js
new file mode 100644
index 0000000000..9e7f65629e
--- /dev/null
+++ b/client/src/lib/textUtils.js
@@ -0,0 +1,26 @@
+// 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.
+ *
+ * This is the ONE client copy — import it, never re-inline the character class.
+ *
+ * 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.
+ */
+export function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/client/src/pages/LoraDatasetDetail.jsx b/client/src/pages/LoraDatasetDetail.jsx
index d245a5932b..cb8bed8a12 100644
--- a/client/src/pages/LoraDatasetDetail.jsx
+++ b/client/src/pages/LoraDatasetDetail.jsx
@@ -28,6 +28,7 @@ import TrainingPanel from '../components/loraTraining/TrainingPanel';
import CaptionModelPicker from '../components/loraTraining/CaptionModelPicker';
import ImportGalleryDialog from '../components/loraTraining/ImportGalleryDialog';
import UniverseCharacterPicker from '../components/loraTraining/UniverseCharacterPicker';
+import { escapeRegExp } from '../lib/textUtils.js';
import {
getLoraDataset,
getLoraDatasetVariationAxes,
@@ -61,7 +62,7 @@ const captionHasTriggerWord = (caption, triggerWord) => {
const text = (caption || '').trim();
if (!text) return false;
if (!word) return true;
- const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const escaped = escapeRegExp(word);
return new RegExp(`(?:^|[^a-z0-9_])${escaped}(?:[^a-z0-9_]|$)`, 'i').test(text);
};
// Mirror of server/lib/loraDataset.js analyzeCaptionInvariants — flags the
@@ -76,7 +77,7 @@ const captionBody = (caption, triggerWord) => {
const word = (triggerWord || '').trim();
let body = (caption || '').trim();
if (word) {
- const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const escaped = escapeRegExp(word);
body = body.replace(new RegExp(`^${escaped}(?=[\\s,]|$)\\s*,?\\s*`, 'i'), '');
}
return body;
diff --git a/server/lib/README.md b/server/lib/README.md
index 61affbdd99..d6c188e279 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -397,7 +397,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `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/`. |
-| `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/`); `kebabCase(text)` is the canonical ASCII slug transform (PLAN.md `[slug]` ids and `planner:` labels). |
+| `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). |
| `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. |
| `postAppliedNumeracy.js` | Pure seeded Applied Numeracy pack — everyday percentage, ratio, unit, rate, and estimation scenarios plus server-authoritative numeric/fraction/unit scoring with explicit tolerance handling. |
diff --git a/server/lib/scenePrompt.js b/server/lib/scenePrompt.js
index 096d8314ed..615a0715ba 100644
--- a/server/lib/scenePrompt.js
+++ b/server/lib/scenePrompt.js
@@ -4,6 +4,7 @@
*/
import { mapCanonDescriptorFragments, richCanonDescriptorFragments } from './canonPrompt.js';
+import { escapeRegExp } from './textUtils.js';
const PROMPT_MAX = 1900;
@@ -78,7 +79,7 @@ function matchEntriesByCandidates(text, entries, candidatesFn) {
const seen = new Set();
const wordBoundary = (needle) => {
if (!needle) return false;
- const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ 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}_]`
diff --git a/server/lib/testHelper.js b/server/lib/testHelper.js
index 6f2e0391a7..dff5865249 100644
--- a/server/lib/testHelper.js
+++ b/server/lib/testHelper.js
@@ -19,6 +19,9 @@ import { fileURLToPath } from 'url';
/** The `server/` root — the scan root for the source-guard helpers below. */
export const SERVER_DIR = fileURLToPath(new URL('..', import.meta.url));
+/** The `client/src/` root — the scan root for the client-side source guards. */
+export const CLIENT_SRC_DIR = fileURLToPath(new URL('../../client/src/', import.meta.url));
+
function startServer(app) {
return new Promise((resolve, reject) => {
const server = createServer(app);
@@ -235,6 +238,50 @@ export function readServerSource(rel) {
return readFileSync(join(SERVER_DIR, rel), 'utf8');
}
+/**
+ * Walk `client/src/` and return every source file, relative to that root.
+ *
+ * The client counterpart of `collectServerSources`, for the guards that must
+ * cover BOTH sides of a server/client mirror — `textUtils.test.js`'s
+ * "no private escapeRegExp" scan is the first, since the escape's client copies
+ * are exactly what a `server/`-only walk could never see.
+ *
+ * Two deliberate differences from the server walk:
+ * - `.jsx` counts. Half the client tree is components, and the escape was
+ * re-inlined in two of them — a `.js`-only walk would report a clean tree.
+ * - `*.test.js` is INCLUDED. The server walk skips tests because the guard
+ * that reads it lives in `server/` and would flag itself; a client test has
+ * no such exemption to claim, and one of the re-inlined copies this closes
+ * lived in a client test file.
+ *
+ * @param {string} [dir] - directory to walk (defaults to `client/src/`)
+ * @returns {string[]} paths relative to `client/src/`, e.g. `lib/scenePrompt.js`
+ */
+export function collectClientSources(dir = CLIENT_SRC_DIR) {
+ // Same vanishing-directory tolerance as the server walk — see the note there.
+ let entries;
+ try {
+ entries = readdirSync(dir, { withFileTypes: true });
+ } catch (err) {
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return [];
+ throw err;
+ }
+ return entries.flatMap((entry) => {
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) return [];
+ const abs = join(dir, entry.name);
+ if (entry.isDirectory()) return collectClientSources(abs);
+ if (!entry.name.endsWith('.js') && !entry.name.endsWith('.jsx')) return [];
+ // POSIX separators always — these are IDENTIFIERS compared against literals
+ // in guard tables, not paths to open. See `collectServerSources`.
+ return [relative(CLIENT_SRC_DIR, abs).split('\\').join('/')];
+ });
+}
+
+/** Read a source file named by a `collectClientSources()` path. */
+export function readClientSource(rel) {
+ return readFileSync(join(CLIENT_SRC_DIR, rel), 'utf8');
+}
+
/**
* Normalize a path for comparison against a POSIX-spelled literal.
*
diff --git a/server/lib/textUtils.test.js b/server/lib/textUtils.test.js
index 80a4c4418a..5b4a88dcb0 100644
--- a/server/lib/textUtils.test.js
+++ b/server/lib/textUtils.test.js
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { countWords, escapeRegExp, trimTo } from './textUtils.js';
-import { collectServerSources, readServerSource } from './testHelper.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', () => {
@@ -80,12 +84,16 @@ describe('escapeRegExp', () => {
// ever grew was a byte-identical paste under a different name (or no name at all).
// The `'\\$&'` replacement is the spelling-independent half — it is what makes a
// `.replace` an escape rather than an edit, and after this migration it appears in
-// exactly the two files below. Consequence: even quoting the idiom in a comment
-// trips the guard — describe the rule in prose, or put the example in textUtils.js,
-// which is the file that owns it.
+// exactly two source files repo-wide: `server/lib/textUtils.js` and its client
+// mirror. Consequence: even quoting the idiom in a comment trips the guard —
+// describe the rule in prose, or put the example in a textUtils.js, which is the
+// file that owns it on each side.
//
// Scope: `collectServerSources` walks all of `server/` but skips `*.test.js`, so
-// this covers product code. A copy in a test can't change what the server does.
+// that half covers product code — a copy in a server test can't change what the
+// server does, and this very file spells the idiom. `collectClientSources` walks
+// `client/src/` and does NOT skip tests (nor `.jsx`), because nothing over there
+// needs the exemption and a client test was one of the copies #5790 migrated.
const ESCAPE_IDIOMS = [
// The self-referential replacement every copy of the escape uses.
/'\\\\\$&'/,
@@ -95,12 +103,11 @@ const ESCAPE_IDIOMS = [
/(?:^|[^\w$.])(?:const|let|var|function)\s+escapeRegExp\b/,
];
-// `scenePrompt.js` is held byte-for-byte identical to `client/src/lib/scenePrompt.js`
-// by the mirror-parity suite in scenePrompt.test.js, and the browser cannot import
-// `server/lib`. Migrating it needs a client-side textUtils mirror first — #5790,
-// which also deletes this entry. It is the one exemption; do not add another
-// without an issue that removes it.
-const HOLDOUT = 'lib/scenePrompt.js';
+// 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)
@@ -109,7 +116,7 @@ const escapeIdiomCount = (source) => ESCAPE_IDIOMS
describe('no private escapeRegExp', () => {
it('leaves lib/textUtils.js as the only RegExp-escape implementation under server/', () => {
const offenders = collectServerSources()
- .filter((rel) => rel !== 'lib/textUtils.js' && rel !== HOLDOUT)
+ .filter((rel) => rel !== 'lib/textUtils.js')
.filter((rel) => escapeIdiomCount(readServerSource(rel)) > 0);
expect(
offenders,
@@ -117,8 +124,42 @@ describe('no private escapeRegExp', () => {
).toEqual([]);
});
- // The holdout is an exemption for ONE known copy, not a licence for the file.
- it('holds the exempt mirror to its single known copy', () => {
- expect(escapeIdiomCount(readServerSource(HOLDOUT))).toBe(1);
+ // 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/', () => {
+ const offenders = collectClientSources()
+ .filter((rel) => rel !== CLIENT_OWNER)
+ .filter((rel) => escapeIdiomCount(readClientSource(rel)) > 0);
+ expect(
+ offenders,
+ `these re-inline the RegExp escape — import escapeRegExp from lib/textUtils.js instead: ${offenders.join(', ')}`
+ ).toEqual([]);
+ });
+
+ // Both walks feed the same detector, so pin that it actually fires — an empty
+ // 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);
+ 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);
});
});