1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 */78import {CompilerError, SourceLocation} from '..';9import {10 BlockId,11 Effect,12 HIRFunction,13 Identifier,14 IdentifierId,15 InstructionId,16 isJsxType,17 makeInstructionId,18 ValueKind,19 ValueReason,20 Place,21 isPrimitiveType,22} from '../HIR/HIR';23import {Environment} from '../HIR/Environment';24import {25 eachInstructionLValue,26 eachInstructionValueOperand,27 eachTerminalOperand,28} from '../HIR/visitors';29import {assertExhaustive, getOrInsertWith} from '../Utils/utils';3031import {AliasingEffect, MutationReason} from './AliasingEffects';3233/**34 * This pass builds an abstract model of the heap and interprets the effects of the35 * given function in order to determine the following:36 * - The mutable ranges of all identifiers in the function37 * - The externally-visible effects of the function, such as mutations of params and38 * context-vars, aliasing between params/context-vars/return-value, and impure side39 * effects.40 * - The legacy `Effect` to store on each Place.41 *42 * This pass builds a data flow graph using the effects, tracking an abstract notion43 * of "when" each effect occurs relative to the others. It then walks each mutation44 * effect against the graph, updating the range of each node that would be reachable45 * at the "time" that the effect occurred.46 *47 * This pass also validates against invalid effects: any function that is reachable48 * by being called, or via a Render effect, is validated against mutating globals49 * or calling impure code.50 *51 * Note that this function also populates the outer function's aliasing effects with52 * any mutations that apply to its params or context variables.53 *54 * ## Example55 * A function expression such as the following:56 *57 * ```58 * (x) => { x.y = true }59 * ```60 *61 * Would populate a `Mutate x` aliasing effect on the outer function.62 *63 * ## Returned Function Effects64 *65 * The function returns (if successful) a list of externally-visible effects.66 * This is determined by simulating a conditional, transitive mutation against67 * each param, context variable, and return value in turn, and seeing which other68 * such values are affected. If they're affected, they must be captured, so we69 * record a Capture.70 *71 * The only tricky bit is the return value, which could _alias_ (or even assign)72 * one or more of the params/context-vars rather than just capturing. So we have73 * to do a bit more tracking for returns.74 */75export function inferMutationAliasingRanges(76 fn: HIRFunction,77 {isFunctionExpression}: {isFunctionExpression: boolean},78): Array<AliasingEffect> {79 // The set of externally-visible effects80 const functionEffects: Array<AliasingEffect> = [];8182 /**83 * Part 1: Infer mutable ranges for values. We build an abstract model of84 * values, the alias/capture edges between them, and the set of mutations.85 * Edges and mutations are ordered, with mutations processed against the86 * abstract model only after it is fully constructed by visiting all blocks87 * _and_ connecting phis. Phis are considered ordered at the time of the88 * phi node.89 *90 * This should (may?) mean that mutations are able to see the full state91 * of the graph and mark all the appropriate identifiers as mutated at92 * the correct point, accounting for both backward and forward edges.93 * Ie a mutation of x accounts for both values that flowed into x,94 * and values that x flowed into.95 */96 const state = new AliasingState();97 type PendingPhiOperand = {from: Place; into: Place; index: number};98 const pendingPhis = new Map<BlockId, Array<PendingPhiOperand>>();99 const mutations: Array<{100 index: number;101 id: InstructionId;102 transitive: boolean;103 kind: MutationKind;104 place: Place;105 reason: MutationReason | null;106 }> = [];107 const renders: Array<{index: number; place: Place}> = [];108109 let index = 0;110111 const shouldRecordErrors = !isFunctionExpression && fn.env.enableValidations;112113 for (const param of [...fn.params, ...fn.context, fn.returns]) {114 const place = param.kind === 'Identifier' ? param : param.place;115 state.create(place, {kind: 'Object'});116 }117 const seenBlocks = new Set<BlockId>();118 for (const block of fn.body.blocks.values()) {119 for (const phi of block.phis) {120 state.create(phi.place, {kind: 'Phi'});121 for (const [pred, operand] of phi.operands) {122 if (!seenBlocks.has(pred)) {123 // NOTE: annotation required to actually typecheck and not silently infer `any`124 const blockPhis = getOrInsertWith<BlockId, Array<PendingPhiOperand>>(125 pendingPhis,126 pred,127 () => [],128 );129 blockPhis.push({from: operand, into: phi.place, index: index++});130 } else {131 state.assign(index++, operand, phi.place);132 }133 }134 }135 seenBlocks.add(block.id);136137 for (const instr of block.instructions) {138 if (instr.effects == null) continue;139 for (const effect of instr.effects) {140 if (effect.kind === 'Create') {141 state.create(effect.into, {kind: 'Object'});142 } else if (effect.kind === 'CreateFunction') {143 state.create(effect.into, {144 kind: 'Function',145 function: effect.function.loweredFunc.func,146 });147 } else if (effect.kind === 'CreateFrom') {148 state.createFrom(index++, effect.from, effect.into);149 } else if (effect.kind === 'Assign') {150 /**151 * TODO: Invariant that the node is not initialized yet152 *153 * InferFunctionExpressionAliasingEffectSignatures currently infers154 * Assign effects in some places that should be Alias, leading to155 * Assign effects that reinitialize a value. The end result appears to156 * be fine, but we should fix that inference pass so that we add the157 * invariant here.158 */159 if (!state.nodes.has(effect.into.identifier)) {160 state.create(effect.into, {kind: 'Object'});161 }162 state.assign(index++, effect.from, effect.into);163 } else if (effect.kind === 'Alias') {164 state.assign(index++, effect.from, effect.into);165 } else if (effect.kind === 'MaybeAlias') {166 state.maybeAlias(index++, effect.from, effect.into);167 } else if (effect.kind === 'Capture') {168 state.capture(index++, effect.from, effect.into);169 } else if (170 effect.kind === 'MutateTransitive' ||171 effect.kind === 'MutateTransitiveConditionally'172 ) {173 mutations.push({174 index: index++,175 id: instr.id,176 transitive: true,177 kind:178 effect.kind === 'MutateTransitive'179 ? MutationKind.Definite180 : MutationKind.Conditional,181 reason: null,182 place: effect.value,183 });184 } else if (185 effect.kind === 'Mutate' ||186 effect.kind === 'MutateConditionally'187 ) {188 mutations.push({189 index: index++,190 id: instr.id,191 transitive: false,192 kind:193 effect.kind === 'Mutate'194 ? MutationKind.Definite195 : MutationKind.Conditional,196 reason: effect.kind === 'Mutate' ? (effect.reason ?? null) : null,197 place: effect.value,198 });199 } else if (200 effect.kind === 'MutateFrozen' ||201 effect.kind === 'MutateGlobal' ||202 effect.kind === 'Impure'203 ) {204 if (shouldRecordErrors) {205 fn.env.recordError(effect.error);206 }207 functionEffects.push(effect);208 } else if (effect.kind === 'Render') {209 renders.push({index: index++, place: effect.place});210 functionEffects.push(effect);211 }212 }213 }214 const blockPhis = pendingPhis.get(block.id);215 if (blockPhis != null) {216 for (const {from, into, index} of blockPhis) {217 state.assign(index, from, into);218 }219 }220 if (block.terminal.kind === 'return') {221 state.assign(index++, block.terminal.value, fn.returns);222 }223224 if (225 (block.terminal.kind === 'maybe-throw' ||226 block.terminal.kind === 'return') &&227 block.terminal.effects != null228 ) {229 for (const effect of block.terminal.effects) {230 if (effect.kind === 'Alias') {231 state.assign(index++, effect.from, effect.into);232 } else {233 CompilerError.invariant(effect.kind === 'Freeze', {234 reason: `Unexpected '${effect.kind}' effect for MaybeThrow terminal`,235 loc: block.terminal.loc,236 });237 }238 }239 }240 }241242 for (const mutation of mutations) {243 state.mutate(244 mutation.index,245 mutation.place.identifier,246 makeInstructionId(mutation.id + 1),247 mutation.transitive,248 mutation.kind,249 mutation.place.loc,250 mutation.reason,251 shouldRecordErrors ? fn.env : null,252 );253 }254 for (const render of renders) {255 state.render(256 render.index,257 render.place.identifier,258 shouldRecordErrors ? fn.env : null,259 );260 }261 for (const param of [...fn.context, ...fn.params]) {262 const place = param.kind === 'Identifier' ? param : param.place;263264 const node = state.nodes.get(place.identifier);265 if (node == null) {266 continue;267 }268 let mutated = false;269 if (node.local != null) {270 if (node.local.kind === MutationKind.Conditional) {271 mutated = true;272 functionEffects.push({273 kind: 'MutateConditionally',274 value: {...place, loc: node.local.loc},275 });276 } else if (node.local.kind === MutationKind.Definite) {277 mutated = true;278 functionEffects.push({279 kind: 'Mutate',280 value: {...place, loc: node.local.loc},281 reason: node.mutationReason,282 });283 }284 }285 if (node.transitive != null) {286 if (node.transitive.kind === MutationKind.Conditional) {287 mutated = true;288 functionEffects.push({289 kind: 'MutateTransitiveConditionally',290 value: {...place, loc: node.transitive.loc},291 });292 } else if (node.transitive.kind === MutationKind.Definite) {293 mutated = true;294 functionEffects.push({295 kind: 'MutateTransitive',296 value: {...place, loc: node.transitive.loc},297 });298 }299 }300 if (mutated) {301 place.effect = Effect.Capture;302 }303 }304305 /**306 * Part 2307 * Add legacy operand-specific effects based on instruction effects and mutable ranges.308 * Also fixes up operand mutable ranges, making sure that start is non-zero if the value309 * is mutated (depended on by later passes like InferReactiveScopeVariables which uses this310 * to filter spurious mutations of globals, which we now guard against more precisely)311 */312 for (const block of fn.body.blocks.values()) {313 for (const phi of block.phis) {314 // TODO: we don't actually set these effects today!315 phi.place.effect = Effect.Store;316 const isPhiMutatedAfterCreation: boolean =317 phi.place.identifier.mutableRange.end >318 (block.instructions.at(0)?.id ?? block.terminal.id);319 for (const operand of phi.operands.values()) {320 operand.effect = isPhiMutatedAfterCreation321 ? Effect.Capture322 : Effect.Read;323 }324 if (325 isPhiMutatedAfterCreation &&326 phi.place.identifier.mutableRange.start === 0327 ) {328 /*329 * TODO: ideally we'd construct a precise start range, but what really330 * matters is that the phi's range appears mutable (end > start + 1)331 * so we just set the start to the previous instruction before this block332 */333 const firstInstructionIdOfBlock =334 block.instructions.at(0)?.id ?? block.terminal.id;335 phi.place.identifier.mutableRange.start = makeInstructionId(336 firstInstructionIdOfBlock - 1,337 );338 }339 }340 for (const instr of block.instructions) {341 for (const lvalue of eachInstructionLValue(instr)) {342 lvalue.effect = Effect.ConditionallyMutate;343 if (lvalue.identifier.mutableRange.start === 0) {344 lvalue.identifier.mutableRange.start = instr.id;345 }346 if (lvalue.identifier.mutableRange.end === 0) {347 lvalue.identifier.mutableRange.end = makeInstructionId(348 Math.max(instr.id + 1, lvalue.identifier.mutableRange.end),349 );350 }351 }352 for (const operand of eachInstructionValueOperand(instr.value)) {353 operand.effect = Effect.Read;354 }355 if (instr.effects == null) {356 continue;357 }358 const operandEffects = new Map<IdentifierId, Effect>();359 for (const effect of instr.effects) {360 switch (effect.kind) {361 case 'Assign':362 case 'Alias':363 case 'Capture':364 case 'CreateFrom':365 case 'MaybeAlias': {366 const isMutatedOrReassigned =367 effect.into.identifier.mutableRange.end > instr.id;368 if (isMutatedOrReassigned) {369 operandEffects.set(effect.from.identifier.id, Effect.Capture);370 operandEffects.set(effect.into.identifier.id, Effect.Store);371 } else {372 operandEffects.set(effect.from.identifier.id, Effect.Read);373 operandEffects.set(effect.into.identifier.id, Effect.Store);374 }375 break;376 }377 case 'CreateFunction':378 case 'Create': {379 break;380 }381 case 'Mutate': {382 operandEffects.set(effect.value.identifier.id, Effect.Store);383 break;384 }385 case 'Apply': {386 CompilerError.invariant(false, {387 reason: `[AnalyzeFunctions] Expected Apply effects to be replaced with more precise effects`,388 loc: effect.function.loc,389 });390 }391 case 'MutateTransitive':392 case 'MutateConditionally':393 case 'MutateTransitiveConditionally': {394 operandEffects.set(395 effect.value.identifier.id,396 Effect.ConditionallyMutate,397 );398 break;399 }400 case 'Freeze': {401 operandEffects.set(effect.value.identifier.id, Effect.Freeze);402 break;403 }404 case 'ImmutableCapture': {405 // no-op, Read is the default406 break;407 }408 case 'Impure':409 case 'Render':410 case 'MutateFrozen':411 case 'MutateGlobal': {412 // no-op413 break;414 }415 default: {416 assertExhaustive(417 effect,418 `Unexpected effect kind ${(effect as any).kind}`,419 );420 }421 }422 }423 for (const lvalue of eachInstructionLValue(instr)) {424 const effect =425 operandEffects.get(lvalue.identifier.id) ??426 Effect.ConditionallyMutate;427 lvalue.effect = effect;428 }429 for (const operand of eachInstructionValueOperand(instr.value)) {430 if (431 operand.identifier.mutableRange.end > instr.id &&432 operand.identifier.mutableRange.start === 0433 ) {434 operand.identifier.mutableRange.start = instr.id;435 }436 const effect = operandEffects.get(operand.identifier.id) ?? Effect.Read;437 operand.effect = effect;438 }439440 /**441 * This case is targeted at hoisted functions like:442 *443 * ```444 * x();445 * function x() { ... }446 * ```447 *448 * Which turns into:449 *450 * t0 = DeclareContext HoistedFunction x451 * t1 = LoadContext x452 * t2 = CallExpression t1 ( )453 * t3 = FunctionExpression ...454 * t4 = StoreContext Function x = t3455 *456 * If the function had captured mutable values, it would already have its457 * range extended to include the StoreContext. But if the function doesn't458 * capture any mutable values its range won't have been extended yet. We459 * want to ensure that the value is memoized along with the context variable,460 * not independently of it (bc of the way we do codegen for hoisted functions).461 * So here we check for StoreContext rvalues and if they haven't already had462 * their range extended to at least this instruction, we extend it.463 */464 if (465 instr.value.kind === 'StoreContext' &&466 instr.value.value.identifier.mutableRange.end <= instr.id467 ) {468 instr.value.value.identifier.mutableRange.end = makeInstructionId(469 instr.id + 1,470 );471 }472 }473 if (block.terminal.kind === 'return') {474 block.terminal.value.effect = isFunctionExpression475 ? Effect.Read476 : Effect.Freeze;477 } else {478 for (const operand of eachTerminalOperand(block.terminal)) {479 operand.effect = Effect.Read;480 }481 }482 }483484 /**485 * Part 3486 * Finish populating the externally visible effects. Above we bubble-up the side effects487 * (MutateFrozen/MutableGlobal/Impure/Render) as well as mutations of context variables.488 * Here we populate an effect to create the return value as well as populating alias/capture489 * effects for how data flows between the params, context vars, and return.490 */491 const returns = fn.returns.identifier;492 functionEffects.push({493 kind: 'Create',494 into: fn.returns,495 value: isPrimitiveType(returns)496 ? ValueKind.Primitive497 : isJsxType(returns.type)498 ? ValueKind.Frozen499 : ValueKind.Mutable,500 reason: ValueReason.KnownReturnSignature,501 });502 /**503 * Determine precise data-flow effects by simulating transitive mutations of the params/504 * captures and seeing what other params/context variables are affected. Anything that505 * would be transitively mutated needs a capture relationship.506 */507 const tracked: Array<Place> = [];508 for (const param of [...fn.params, ...fn.context, fn.returns]) {509 const place = param.kind === 'Identifier' ? param : param.place;510 tracked.push(place);511 }512 for (const into of tracked) {513 const mutationIndex = index++;514 state.mutate(515 mutationIndex,516 into.identifier,517 null,518 true,519 MutationKind.Conditional,520 into.loc,521 null,522 null,523 );524 for (const from of tracked) {525 if (526 from.identifier.id === into.identifier.id ||527 from.identifier.id === fn.returns.identifier.id528 ) {529 continue;530 }531 const fromNode = state.nodes.get(from.identifier);532 CompilerError.invariant(fromNode != null, {533 reason: `Expected a node to exist for all parameters and context variables`,534 loc: into.loc,535 });536 if (fromNode.lastMutated === mutationIndex) {537 if (into.identifier.id === fn.returns.identifier.id) {538 // The return value could be any of the params/context variables539 functionEffects.push({540 kind: 'Alias',541 from,542 into,543 });544 } else {545 // Otherwise params/context-vars can only capture each other546 functionEffects.push({547 kind: 'Capture',548 from,549 into,550 });551 }552 }553 }554 }555556 return functionEffects;557}558559function appendFunctionErrors(env: Environment | null, fn: HIRFunction): void {560 if (env == null) return;561 for (const effect of fn.aliasingEffects ?? []) {562 switch (effect.kind) {563 case 'Impure':564 case 'MutateFrozen':565 case 'MutateGlobal': {566 env.recordError(effect.error);567 break;568 }569 }570 }571}572573export enum MutationKind {574 None = 0,575 Conditional = 1,576 Definite = 2,577}578579type Node = {580 id: Identifier;581 createdFrom: Map<Identifier, number>;582 captures: Map<Identifier, number>;583 aliases: Map<Identifier, number>;584 maybeAliases: Map<Identifier, number>;585 edges: Array<{586 index: number;587 node: Identifier;588 kind: 'capture' | 'alias' | 'maybeAlias';589 }>;590 transitive: {kind: MutationKind; loc: SourceLocation} | null;591 local: {kind: MutationKind; loc: SourceLocation} | null;592 lastMutated: number;593 mutationReason: MutationReason | null;594 value:595 | {kind: 'Object'}596 | {kind: 'Phi'}597 | {kind: 'Function'; function: HIRFunction};598};599class AliasingState {600 nodes: Map<Identifier, Node> = new Map();601602 create(place: Place, value: Node['value']): void {603 this.nodes.set(place.identifier, {604 id: place.identifier,605 createdFrom: new Map(),606 captures: new Map(),607 aliases: new Map(),608 maybeAliases: new Map(),609 edges: [],610 transitive: null,611 local: null,612 lastMutated: 0,613 mutationReason: null,614 value,615 });616 }617618 createFrom(index: number, from: Place, into: Place): void {619 this.create(into, {kind: 'Object'});620 const fromNode = this.nodes.get(from.identifier);621 const toNode = this.nodes.get(into.identifier);622 if (fromNode == null || toNode == null) {623 return;624 }625 fromNode.edges.push({index, node: into.identifier, kind: 'alias'});626 if (!toNode.createdFrom.has(from.identifier)) {627 toNode.createdFrom.set(from.identifier, index);628 }629 }630631 capture(index: number, from: Place, into: Place): void {632 const fromNode = this.nodes.get(from.identifier);633 const toNode = this.nodes.get(into.identifier);634 if (fromNode == null || toNode == null) {635 return;636 }637 fromNode.edges.push({index, node: into.identifier, kind: 'capture'});638 if (!toNode.captures.has(from.identifier)) {639 toNode.captures.set(from.identifier, index);640 }641 }642643 assign(index: number, from: Place, into: Place): void {644 const fromNode = this.nodes.get(from.identifier);645 const toNode = this.nodes.get(into.identifier);646 if (fromNode == null || toNode == null) {647 return;648 }649 fromNode.edges.push({index, node: into.identifier, kind: 'alias'});650 if (!toNode.aliases.has(from.identifier)) {651 toNode.aliases.set(from.identifier, index);652 }653 }654655 maybeAlias(index: number, from: Place, into: Place): void {656 const fromNode = this.nodes.get(from.identifier);657 const toNode = this.nodes.get(into.identifier);658 if (fromNode == null || toNode == null) {659 return;660 }661 fromNode.edges.push({index, node: into.identifier, kind: 'maybeAlias'});662 if (!toNode.maybeAliases.has(from.identifier)) {663 toNode.maybeAliases.set(from.identifier, index);664 }665 }666667 render(index: number, start: Identifier, env: Environment | null): void {668 const seen = new Set<Identifier>();669 const queue: Array<Identifier> = [start];670 while (queue.length !== 0) {671 const current = queue.pop()!;672 if (seen.has(current)) {673 continue;674 }675 seen.add(current);676 const node = this.nodes.get(current);677 if (node == null || node.transitive != null || node.local != null) {678 continue;679 }680 if (node.value.kind === 'Function') {681 appendFunctionErrors(env, node.value.function);682 }683 for (const [alias, when] of node.createdFrom) {684 if (when >= index) {685 continue;686 }687 queue.push(alias);688 }689 for (const [alias, when] of node.aliases) {690 if (when >= index) {691 continue;692 }693 queue.push(alias);694 }695 for (const [capture, when] of node.captures) {696 if (when >= index) {697 continue;698 }699 queue.push(capture);700 }701 }702 }703704 mutate(705 index: number,706 start: Identifier,707 // Null is used for simulated mutations708 end: InstructionId | null,709 transitive: boolean,710 startKind: MutationKind,711 loc: SourceLocation,712 reason: MutationReason | null,713 env: Environment | null,714 ): void {715 const seen = new Map<Identifier, MutationKind>();716 const queue: Array<{717 place: Identifier;718 transitive: boolean;719 direction: 'backwards' | 'forwards';720 kind: MutationKind;721 }> = [{place: start, transitive, direction: 'backwards', kind: startKind}];722 while (queue.length !== 0) {723 const {place: current, transitive, direction, kind} = queue.pop()!;724 const previousKind = seen.get(current);725 if (previousKind != null && previousKind >= kind) {726 continue;727 }728 seen.set(current, kind);729 const node = this.nodes.get(current);730 if (node == null) {731 continue;732 }733 node.mutationReason ??= reason;734 node.lastMutated = Math.max(node.lastMutated, index);735 if (end != null) {736 node.id.mutableRange.end = makeInstructionId(737 Math.max(node.id.mutableRange.end, end),738 );739 }740 if (741 node.value.kind === 'Function' &&742 node.transitive == null &&743 node.local == null744 ) {745 appendFunctionErrors(env, node.value.function);746 }747 if (transitive) {748 if (node.transitive == null || node.transitive.kind < kind) {749 node.transitive = {kind, loc};750 }751 } else {752 if (node.local == null || node.local.kind < kind) {753 node.local = {kind, loc};754 }755 }756 /**757 * all mutations affect "forward" edges by the rules:758 * - Capture a -> b, mutate(a) => mutate(b)759 * - Alias a -> b, mutate(a) => mutate(b)760 */761 for (const edge of node.edges) {762 if (edge.index >= index) {763 break;764 }765 queue.push({766 place: edge.node,767 transitive,768 direction: 'forwards',769 // Traversing a maybeAlias edge always downgrades to conditional mutation770 kind: edge.kind === 'maybeAlias' ? MutationKind.Conditional : kind,771 });772 }773 for (const [alias, when] of node.createdFrom) {774 if (when >= index) {775 continue;776 }777 queue.push({778 place: alias,779 transitive: true,780 direction: 'backwards',781 kind,782 });783 }784 if (direction === 'backwards' || node.value.kind !== 'Phi') {785 /**786 * all mutations affect backward alias edges by the rules:787 * - Alias a -> b, mutate(b) => mutate(a)788 * - Alias a -> b, mutateTransitive(b) => mutate(a)789 *790 * However, if we reached a phi because one of its inputs was mutated791 * (and we're advancing "forwards" through that node's edges), then792 * we know we've already processed the mutation at its source. The793 * phi's other inputs can't be affected.794 */795 for (const [alias, when] of node.aliases) {796 if (when >= index) {797 continue;798 }799 queue.push({800 place: alias,801 transitive,802 direction: 'backwards',803 kind,804 });805 }806 /**807 * MaybeAlias indicates potential data flow from unknown function calls,808 * so we downgrade mutations through these aliases to consider them809 * conditional. This means we'll consider them for mutation *range*810 * purposes but not report validation errors for mutations, since811 * we aren't sure that the `from` value could actually be aliased.812 */813 for (const [alias, when] of node.maybeAliases) {814 if (when >= index) {815 continue;816 }817 queue.push({818 place: alias,819 transitive,820 direction: 'backwards',821 kind: MutationKind.Conditional,822 });823 }824 }825 /**826 * but only transitive mutations affect captures827 */828 if (transitive) {829 for (const [capture, when] of node.captures) {830 if (when >= index) {831 continue;832 }833 queue.push({834 place: capture,835 transitive,836 direction: 'backwards',837 kind,838 });839 }840 }841 }842 }843}
Findings
✓ No findings reported for this file.