commit da4a6589d70a8f220e1051834156cc809dc76296
parent b7f42f325b433ad0c3535b864c3aad43194d266d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 16:21:31 -0400
common: applySearchIn — a "transcripts" leaf reads the kinds the Search in row ticks
A pure rewrite of the committed tree just before it runs: a leaf whose scope
is "transcripts" becomes, per the row, the same leaf (transcripts only), the
leaf with another scope (one other kind), or an OR over one copy per kind
under derived ids (two or three kinds; a negated leaf is NOT of the union).
A leaf asked for by name is untouched, and so is the visitor's tree, its
hash and its qt=. foldSearchIn (lib/search/searchIn.ts) files the copies'
hits and states back under the visitor's leaf, the count being the OR's
union once every copy has started. Tests, including runs through the real
tree evaluator with a fake leaf runner.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 587 insertions(+), 0 deletions(-)
diff --git a/common/lib/search/searchIn.test.ts b/common/lib/search/searchIn.test.ts
@@ -0,0 +1,201 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ applySearchIn,
+ newGroup,
+ newLeaf,
+ type GroupNode,
+ type SearchIn,
+} from "../searchQuery";
+import {
+ runQueryTree,
+ type LayerCache,
+ type TreeProgress,
+} from "../searchEval";
+import type { LayerHit, LeafController, LeafRunner } from "./leafPipeline";
+import { foldSearchIn } from "./searchIn";
+
+// The "Search in" fold, and the rewrite run end to end through the real tree
+// evaluator with a fake leaf runner: a transcripts leaf reads the kinds ticked,
+// and what the visitor sees is filed under the leaf they built.
+
+// A tiny corpus. v2 is the only video with live chat; p1 is a post.
+const TEXT: Record<string, Record<string, string>> = {
+ transcripts: { v1: "an alpha line", v2: "a beta line", v3: "a gamma line" },
+ chat: { v2: "@u: alpha in chat" },
+ posts: { p1: "a post about alpha" },
+};
+
+const noCache: LayerCache = {
+ key: (a, b) => `${a}__${b}`,
+ getSync: () => null,
+ get: async () => null,
+ put: () => {},
+};
+
+// Matches `leaf.query` against the text for the leaf's scope, one hit per
+// matching slug, and reports it the way runLeafPipeline does (a chat hit
+// carries its track).
+const fakeRunLeaf: LeafRunner = (opts) => {
+ const { leaf, scopeSlugs, emit } = opts;
+ const slugs = new Set<string>();
+ const hits = new Map<string, LayerHit[]>();
+ for (const slug of scopeSlugs) {
+ const text = TEXT[leaf.scope]?.[slug];
+ if (!text || !text.includes(leaf.query)) continue;
+ slugs.add(slug);
+ if (leaf.contributeHits) {
+ hits.set(slug, [
+ {
+ leafId: leaf.id,
+ scope: leaf.scope,
+ ...(leaf.scope === "chat" ? { track: "live_chat" } : {}),
+ start: 1,
+ text,
+ },
+ ]);
+ }
+ }
+ let resolve!: (r: { slugs: Set<string>; hits: Map<string, LayerHit[]> }) => void;
+ const done = new Promise<{ slugs: Set<string>; hits: Map<string, LayerHit[]> }>(
+ (r) => (resolve = r),
+ );
+ queueMicrotask(() => {
+ emit({
+ slugs,
+ hits,
+ totalHits: [...hits.values()].reduce((n, l) => n + l.length, 0),
+ processed: scopeSlugs.length,
+ totalToProcess: scopeSlugs.length,
+ capped: false,
+ done: true,
+ });
+ resolve({ slugs, hits });
+ });
+ const ctrl: LeafController = { cancel() {}, setHitLimit() {}, done };
+ return ctrl;
+};
+
+function run(root: GroupNode, s: SearchIn): Promise<TreeProgress> {
+ const rewritten = applySearchIn(root, s);
+ return new Promise((resolve) => {
+ runQueryTree({
+ root: rewritten.root,
+ runtime: { runLeaf: fakeRunLeaf, cache: noCache },
+ // The session's global scope: the videos, and the posts when Posts is on.
+ globalScope: ["v1", "v2", "v3", ...(s.posts ? ["p1"] : [])],
+ summaries: [],
+ chatScopeSlugs: new Set(["v2"]),
+ postScopeSlugs: new Set(["p1"]),
+ initialHitLimit: 500,
+ concurrency: 2,
+ flushIntervalMs: 10,
+ emit: (p) => {
+ if (p.done) resolve(foldSearchIn(p, rewritten));
+ },
+ });
+ });
+}
+
+const ALPHA = () => newGroup({ id: "root", children: [newLeaf({ id: "a", query: "alpha" })] });
+
+test("Transcripts on: the transcript cues only (today's plain query)", async () => {
+ const p = await run(ALPHA(), { transcripts: true, posts: false, chat: false });
+ assert.deepEqual([...p.slugs].sort(), ["v1"]);
+});
+
+test("Transcripts + Posts (the default): videos and the post, in one result set", async () => {
+ const p = await run(ALPHA(), { transcripts: true, posts: true, chat: false });
+ assert.deepEqual([...p.slugs].sort(), ["p1", "v1"]);
+ assert.equal(p.hits.get("p1")?.[0].scope, "posts");
+ assert.equal(p.hits.get("p1")?.[0].leafId, "a", "filed under the visitor's leaf");
+});
+
+test("Live chat on: a chat hit lands in the same leaf, wearing its track", async () => {
+ const p = await run(ALPHA(), { transcripts: true, posts: false, chat: true });
+ assert.deepEqual([...p.slugs].sort(), ["v1", "v2"]);
+ const chat = p.hits.get("v2")?.[0];
+ assert.equal(chat?.leafId, "a");
+ assert.equal(chat?.scope, "chat");
+ assert.equal(chat?.track, "live_chat");
+ // The copies' states fold into the leaf's: no copy id is left for the
+ // builder to miss, and the count is the union.
+ assert.deepEqual([...p.leafStates.keys()], ["a"]);
+ assert.equal(p.leafStates.get("a")?.slugCount, 2);
+ assert.equal(p.leafStates.get("a")?.totalHits, 2);
+ assert.equal(p.leafStates.get("a")?.active, false);
+});
+
+test("Transcripts off + Live chat on: a word only in the cues returns nothing", async () => {
+ const off: SearchIn = { transcripts: false, posts: false, chat: true };
+ const onlyInCues = newGroup({ children: [newLeaf({ id: "b", query: "beta" })] });
+ assert.equal((await run(onlyInCues, off)).slugs.size, 0);
+ const p = await run(ALPHA(), off);
+ assert.deepEqual([...p.slugs], ["v2"]);
+});
+
+test("a leaf asked for by name ignores the row", async () => {
+ const chatByName = newGroup({
+ children: [newLeaf({ id: "c", query: "alpha", scope: "chat" })],
+ });
+ const p = await run(chatByName, { transcripts: true, posts: true, chat: false });
+ assert.deepEqual([...p.slugs], ["v2"]);
+});
+
+test("NOT reads NOT of the union, and its count is still what it matched", async () => {
+ const root = newGroup({
+ children: [newLeaf({ id: "a", query: "alpha", negate: true })],
+ });
+ const p = await run(root, { transcripts: true, posts: false, chat: true });
+ // alpha is in v1's cues and v2's chat, so only v3 is left.
+ assert.deepEqual([...p.slugs], ["v3"]);
+ assert.equal(p.leafStates.get("a")?.slugCount, 2);
+});
+
+test("the fold before every copy has started: the largest copy, still active", () => {
+ const { origin, unionOf } = applySearchIn(ALPHA(), {
+ transcripts: true,
+ posts: true,
+ chat: true,
+ });
+ const state = (slugCount: number) => ({
+ slugCount,
+ totalHits: slugCount,
+ processed: 1,
+ totalToProcess: 4,
+ capped: false,
+ cached: false,
+ active: false,
+ });
+ const p: TreeProgress = {
+ slugs: new Set(),
+ hits: new Map(),
+ leafStates: new Map([
+ ["a~transcripts", state(3)],
+ ["a~posts", state(1)],
+ ]),
+ // What the evaluator reports while a copy has no result: the whole scope.
+ groupStates: new Map([["a~in", { slugCount: 30_000 }]]),
+ done: false,
+ capped: false,
+ };
+ const folded = foldSearchIn(p, { origin, unionOf });
+ const a = folded.leafStates.get("a");
+ assert.equal(a?.slugCount, 3);
+ assert.equal(a?.active, true);
+ assert.equal(a?.totalHits, 4);
+ assert.equal(a?.processed, 2);
+ assert.equal(a?.totalToProcess, 8);
+});
+
+test("nothing rewritten: the fold hands back the same progress", () => {
+ const p: TreeProgress = {
+ slugs: new Set(["v1"]),
+ hits: new Map(),
+ leafStates: new Map(),
+ groupStates: new Map(),
+ done: true,
+ capped: false,
+ };
+ assert.equal(foldSearchIn(p, { origin: new Map(), unionOf: new Map() }), p);
+});
diff --git a/common/lib/search/searchIn.ts b/common/lib/search/searchIn.ts
@@ -0,0 +1,94 @@
+// The "Search in" fold — a run of the rewritten tree, read as the tree the
+// visitor built.
+//
+// `applySearchIn` (lib/searchQuery.ts) turns a "transcripts" leaf that reads
+// two or three kinds into an OR over one copy per kind, each copy with an id of
+// its own. The run reports under those ids; the builder, the result cards and
+// the session all know only the visitor's leaf. This fold files every copy's
+// hits and counts back under that leaf, so a chat hit lands in the same
+// section of the same video row as the transcript hits, wearing its track.
+//
+// Pure: it copies what it changes and returns the progress it was given when
+// the rewrite changed nothing.
+
+import type { SearchInTree } from "../searchQuery";
+import type { LeafState, TreeProgress } from "../searchEval";
+import type { LayerHit } from "./leafPipeline";
+
+export function foldSearchIn(
+ p: TreeProgress,
+ tree: Pick<SearchInTree, "origin" | "unionOf">,
+): TreeProgress {
+ const { origin, unionOf } = tree;
+ if (origin.size === 0) return p;
+
+ const hits = new Map<string, LayerHit[]>();
+ for (const [slug, list] of p.hits) {
+ let changed = false;
+ const out = list.map((h) => {
+ const to = origin.get(h.leafId);
+ if (to === undefined) return h;
+ changed = true;
+ return { ...h, leafId: to };
+ });
+ hits.set(slug, changed ? out : list);
+ }
+
+ // Each copy's state, grouped under the leaf it stands for. A copy that has
+ // not started yet has no state at all.
+ const copies = new Map<string, LeafState[]>();
+ const copiesExpected = new Map<string, number>();
+ for (const to of origin.values()) {
+ copiesExpected.set(to, (copiesExpected.get(to) ?? 0) + 1);
+ }
+ const leafStates = new Map<string, LeafState>();
+ for (const [id, state] of p.leafStates) {
+ const to = origin.get(id);
+ if (to === undefined) {
+ leafStates.set(id, state);
+ continue;
+ }
+ const list = copies.get(to) ?? [];
+ list.push(state);
+ copies.set(to, list);
+ }
+
+ for (const [to, states] of copies) {
+ const allStarted = states.length === (copiesExpected.get(to) ?? 0);
+ // The OR group's state is the union of what the copies matched, inside the
+ // same scope the leaf itself would have read — the count the leaf shows.
+ // Until every copy has started it is not: the evaluator reads a copy with
+ // no result yet as "everything in scope", so the union would briefly be the
+ // whole scope. Until then the largest copy is the count (a lower bound).
+ const groupId = unionOf.get(to);
+ const union = groupId ? p.groupStates.get(groupId) : undefined;
+ let slugCount = 0;
+ if (allStarted && union) slugCount = union.slugCount;
+ else for (const s of states) slugCount = Math.max(slugCount, s.slugCount);
+ let totalHits = 0;
+ let processed = 0;
+ let totalToProcess = 0;
+ let capped = false;
+ let cached = true;
+ let active = !allStarted;
+ for (const s of states) {
+ totalHits += s.totalHits;
+ processed += s.processed;
+ totalToProcess += s.totalToProcess;
+ capped ||= s.capped;
+ cached &&= s.cached;
+ active ||= s.active;
+ }
+ leafStates.set(to, {
+ slugCount,
+ totalHits,
+ processed,
+ totalToProcess,
+ capped,
+ cached,
+ active,
+ });
+ }
+
+ return { ...p, hits, leafStates };
+}
diff --git a/common/lib/searchQuery.test.ts b/common/lib/searchQuery.test.ts
@@ -0,0 +1,184 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ SEARCH_IN_DEFAULT,
+ applySearchIn,
+ canonicalHash,
+ isGroup,
+ isLeaf,
+ newGroup,
+ newLeaf,
+ searchInReadsNothing,
+ stringifyRoot,
+ type GroupNode,
+ type LeafNode,
+ type SearchIn,
+} from "./searchQuery";
+
+// The "Search in" rewrite (release 16, slice CK): what a "transcripts" leaf
+// reads is the row's business, a leaf asked for by name is not.
+
+const T_ONLY: SearchIn = { transcripts: true, posts: false, chat: false };
+
+function tree(...children: GroupNode["children"]): GroupNode {
+ return newGroup({ id: "root", children });
+}
+
+test("the default row with no posts corpus changes nothing — the very same root", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const r = applySearchIn(root, T_ONLY);
+ assert.equal(r.root, root);
+ assert.equal(r.origin.size, 0);
+ assert.equal(r.unionOf.size, 0);
+});
+
+test("the ruling's default is Transcripts and Posts on, Live chat off", () => {
+ assert.deepEqual(SEARCH_IN_DEFAULT, {
+ transcripts: true,
+ posts: true,
+ chat: false,
+ });
+});
+
+test("Transcripts + Live chat: the leaf becomes OR(transcripts, chat) under derived ids", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const r = applySearchIn(root, { transcripts: true, posts: false, chat: true });
+ const [or] = r.root.children;
+ assert.ok(isGroup(or));
+ assert.equal(or.id, "a~in");
+ assert.equal(or.op, "OR");
+ assert.equal(or.negate, false);
+ assert.deepEqual(
+ or.children.map((c) => [c.id, (c as LeafNode).scope, (c as LeafNode).query]),
+ [
+ ["a~transcripts", "transcripts", "alpha"],
+ ["a~chat", "chat", "alpha"],
+ ],
+ );
+ assert.deepEqual(
+ [...r.origin],
+ [
+ ["a~transcripts", "a"],
+ ["a~chat", "a"],
+ ],
+ );
+ assert.deepEqual([...r.unionOf], [["a", "a~in"]]);
+ // The root keeps its id; nothing is mutated.
+ assert.equal(r.root.id, "root");
+ assert.equal(root.children[0].id, "a");
+});
+
+test("Live chat only: the same leaf, scope chat, same id — nothing to fold", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha", useRegex: true }));
+ const r = applySearchIn(root, { transcripts: false, posts: false, chat: true });
+ const [leaf] = r.root.children;
+ assert.ok(isLeaf(leaf));
+ assert.equal(leaf.id, "a");
+ assert.equal(leaf.scope, "chat");
+ assert.equal(leaf.useRegex, true);
+ assert.equal(r.origin.size, 0);
+ assert.equal(r.unionOf.size, 0);
+});
+
+test("Posts only: the same leaf, scope posts", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const r = applySearchIn(root, { transcripts: false, posts: true, chat: false });
+ const [leaf] = r.root.children;
+ assert.ok(isLeaf(leaf));
+ assert.equal(leaf.scope, "posts");
+ assert.equal(leaf.id, "a");
+});
+
+test("all three: one copy per kind, in a fixed order", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const r = applySearchIn(root, { transcripts: true, posts: true, chat: true });
+ const [or] = r.root.children;
+ assert.ok(isGroup(or));
+ assert.deepEqual(
+ or.children.map((c) => (c as LeafNode).scope),
+ ["transcripts", "posts", "chat"],
+ );
+ assert.equal(r.origin.size, 3);
+});
+
+test("nothing ticked: the tree is left as it is (it reads its transcripts)", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const nothing = { transcripts: false, posts: false, chat: false };
+ assert.ok(searchInReadsNothing(nothing));
+ assert.ok(!searchInReadsNothing(T_ONLY));
+ assert.ok(!searchInReadsNothing({ transcripts: false, posts: false, chat: true }));
+ assert.equal(applySearchIn(root, nothing).root, root);
+});
+
+test("a leaf asked for by name, and an empty leaf, are not the row's business", () => {
+ const byName = ["chat", "posts", "metadata", "description", "tags"] as const;
+ const root = tree(
+ ...byName.map((scope) => newLeaf({ id: scope, query: "alpha", scope })),
+ newLeaf({ id: "empty", query: " " }),
+ );
+ const r = applySearchIn(root, { transcripts: false, posts: true, chat: true });
+ assert.equal(r.root, root, "no leaf changed, so the root is the same object");
+});
+
+test("a negated leaf reads NOT of the union: the OR sits inside a negated AND", () => {
+ const root = tree(
+ newLeaf({ id: "a", query: "alpha", negate: true, contributeHits: false }),
+ );
+ const r = applySearchIn(root, { transcripts: true, posts: false, chat: true });
+ const [not] = r.root.children;
+ assert.ok(isGroup(not));
+ assert.equal(not.id, "a~not");
+ assert.equal(not.op, "AND");
+ assert.equal(not.negate, true);
+ const [or] = not.children;
+ assert.ok(isGroup(or));
+ assert.equal(or.id, "a~in");
+ assert.equal(or.negate, false, "the union itself is never negated");
+ for (const c of or.children) {
+ assert.ok(isLeaf(c));
+ assert.equal(c.negate, false);
+ assert.equal(c.contributeHits, false, "the copy keeps contributeHits");
+ }
+ assert.deepEqual([...r.unionOf], [["a", "a~in"]]);
+});
+
+test("nested: only the transcripts leaf is rewritten; untouched subtrees keep identity", () => {
+ const untouched = newGroup({
+ id: "g2",
+ op: "OR",
+ children: [newLeaf({ id: "m", query: "title", scope: "metadata" })],
+ });
+ const inner = newGroup({
+ id: "g1",
+ op: "AND",
+ children: [newLeaf({ id: "t", query: "beta" })],
+ });
+ const root = tree(untouched, inner);
+ const before = stringifyRoot(root);
+ const hash = canonicalHash(root);
+ const r = applySearchIn(root, { transcripts: true, posts: true, chat: false });
+ assert.equal(r.root.children[0], untouched);
+ const g1 = r.root.children[1];
+ assert.ok(isGroup(g1));
+ assert.equal(g1.id, "g1");
+ assert.equal(g1.children[0].id, "t~in");
+ // The visitor's tree — its URL form and its hash — is untouched.
+ assert.equal(stringifyRoot(root), before);
+ assert.equal(canonicalHash(root), hash);
+});
+
+test("two transcripts leaves each get their own copies", () => {
+ const root = tree(
+ newLeaf({ id: "a", query: "alpha" }),
+ newLeaf({ id: "b", query: "beta" }),
+ );
+ const r = applySearchIn(root, { transcripts: true, posts: true, chat: false });
+ assert.deepEqual(
+ r.root.children.map((c) => c.id),
+ ["a~in", "b~in"],
+ );
+ assert.deepEqual(
+ [...r.origin.values()].sort(),
+ ["a", "a", "b", "b"],
+ );
+});
diff --git a/common/lib/searchQuery.ts b/common/lib/searchQuery.ts
@@ -472,3 +472,111 @@ export function forEachLeaf(
}
for (const c of root.children) forEachLeaf(c, fn);
}
+
+// ─── "Search in" ───
+// The Filters panel's "Search in" row says what the DEFAULT leaf reads: a leaf
+// whose scope is "transcripts" (what `emptyRoot` and every plain query make)
+// reads the transcript cues, the posts corpus and the live-chat track, each
+// when its box is ticked. A leaf asked for BY NAME in the builder ("Live chat",
+// "Posts", "Title / channel", …) is not the row's business and is left as it
+// is. The row is not part of the tree: the tree the visitor built, the `qt=`
+// it writes and the canonical hash stay what they were, and this rewrite is
+// applied to the committed tree just before it runs.
+//
+// `posts` and `chat` are what the row ticks AND the site ships — the caller
+// folds "Posts is ticked but this site has no posts" to false, so a rewrite
+// never asks for a corpus that is not there.
+export type SearchIn = { transcripts: boolean; posts: boolean; chat: boolean };
+
+// Transcripts and posts on, live chat off: the ruling's default.
+export const SEARCH_IN_DEFAULT: Readonly<SearchIn> = {
+ transcripts: true,
+ posts: true,
+ chat: false,
+};
+
+export function searchInReadsNothing(s: SearchIn): boolean {
+ return !s.transcripts && !s.posts && !s.chat;
+}
+
+export type SearchInTree = {
+ root: GroupNode;
+ // A per-kind copy's leaf id → the id of the "transcripts" leaf it stands
+ // for. The result fold (`lib/search/searchIn.ts`) reads it to file the
+ // copies' hits and counts back under the leaf the visitor sees.
+ origin: ReadonlyMap<string, string>;
+ // The "transcripts" leaf's id → the OR group over its copies, whose group
+ // state is the union the leaf's count should read. Only for a leaf that
+ // reads two or three kinds (one kind is the same leaf with another scope).
+ unionOf: ReadonlyMap<string, string>;
+};
+
+// The kinds in the order their copies are made. The order is cosmetic (an OR's
+// children run in parallel and its hash sorts them); fixed so a test can name it.
+const SEARCH_IN_KINDS = ["transcripts", "posts", "chat"] as const;
+
+// Rewrite every ACTIVE "transcripts" leaf to read what the row ticks:
+// transcripts only → the leaf, unchanged (the same object)
+// one other kind only → the leaf with that scope; same id, same negate
+// two or three kinds → an OR group over one copy per kind, each copy with
+// the leaf's `contributeHits`. A negated leaf is NOT
+// of the union: the OR goes inside a negated
+// one-child AND, so the OR's own state is always the
+// union (the count the leaf shows) whatever `negate`.
+// nothing → the leaf, unchanged. The UI never commits it (the
+// Search button, Enter, Apply and the profile saves
+// all refuse); a tree that arrives with nothing ticked
+// some other way reads its transcripts rather than
+// silently matching nothing.
+// Copies get derived ids (`<id>~posts`, `<id>~in`, …) because every leaf in a
+// run keys its own results by id. An unchanged subtree keeps its identity, so
+// the default row returns the very root it was given.
+export function applySearchIn(root: GroupNode, s: SearchIn): SearchInTree {
+ const origin = new Map<string, string>();
+ const unionOf = new Map<string, string>();
+ const kinds = SEARCH_IN_KINDS.filter((k) => s[k]);
+ if (kinds.length === 0 || (kinds.length === 1 && kinds[0] === "transcripts")) {
+ return { root, origin, unionOf };
+ }
+ const out = rewriteSearchIn(root, kinds, origin, unionOf);
+ return { root: out as GroupNode, origin, unionOf };
+}
+
+function rewriteSearchIn(
+ node: QueryNode,
+ kinds: ReadonlyArray<LayerScope>,
+ origin: Map<string, string>,
+ unionOf: Map<string, string>,
+): QueryNode {
+ if (isLeaf(node)) {
+ if (node.scope !== "transcripts" || !isLeafActive(node)) return node;
+ if (kinds.length === 1) return { ...node, scope: kinds[0] };
+ const union: GroupNode = {
+ kind: "group",
+ id: `${node.id}~in`,
+ op: "OR",
+ negate: false,
+ children: kinds.map((scope) => {
+ const id = `${node.id}~${scope}`;
+ origin.set(id, node.id);
+ return { ...node, id, scope, negate: false };
+ }),
+ };
+ unionOf.set(node.id, union.id);
+ if (!node.negate) return union;
+ return {
+ kind: "group",
+ id: `${node.id}~not`,
+ op: "AND",
+ negate: true,
+ children: [union],
+ };
+ }
+ let changed = false;
+ const children = node.children.map((c) => {
+ const r = rewriteSearchIn(c, kinds, origin, unionOf);
+ if (r !== c) changed = true;
+ return r;
+ });
+ return changed ? { ...node, children } : node;
+}