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); }); });