mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
5fbea61e5a
* fix(javascript): report path data records as route candidates
A string `path` or `route` property was reported as an exact, observed
route endpoint whatever object held it, so data records such as
`{ type: "set", path: "updatedAt" }` became routes while a real
`app.get("/x")` stayed a low-confidence candidate.
Keep the property finding, but mark it as a candidate unless its object
has router fields (`component`, `element`, `Component`, `children`, or a
typed `_$` record), using the same shape check that already selects
automatic route roots. Route candidates get a route-specific limitation.
Fixes #6349
* fix(javascript): scan each route record's keys once
`#property` read the enclosing object's keys for every `path` or `route`
property, so an object repeating those keys was scanned quadratically.
Cache the record shape per object and share it with `#object`.
1561 lines
53 KiB
TypeScript
1561 lines
53 KiB
TypeScript
import * as t from "@babel/types";
|
|
import { jsonValueByteLength } from "../jsonValueByteLength.js";
|
|
import type { JsonValue } from "../jsonValue.js";
|
|
import { JAVASCRIPT_STATIC_FACT_BYTES } from "./javascriptAnalysisResourceControls.js";
|
|
import {
|
|
semanticMemberChain,
|
|
isObjectPrototypeSetter,
|
|
semanticStaticPropertyName,
|
|
staticJavaScriptTruthiness,
|
|
semanticStaticPropertyKey,
|
|
javaScriptExpressionWrapperTarget,
|
|
unwrapJavaScriptExpression,
|
|
} from "./javascriptAstValues.js";
|
|
import {
|
|
calleeName,
|
|
range as sourceRange,
|
|
STATIC_ROUTE_CALL_NAMES,
|
|
stringValue,
|
|
} from "./javascriptStaticAnalysisHelpers.js";
|
|
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
|
|
import {
|
|
semanticStableInitializer,
|
|
type JavaScriptBindingIndex,
|
|
type JavaScriptSemanticBindingState,
|
|
} from "./javascriptSemanticState.js";
|
|
import {
|
|
semanticCopiedReferencePath,
|
|
semanticReferenceSourcesInactive,
|
|
} from "./javascriptSemanticReferenceSources.js";
|
|
import {
|
|
semanticAliasSelectionChoices,
|
|
type SemanticAliasSelectionPath,
|
|
} from "./javascriptSemanticAliasSelections.js";
|
|
import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js";
|
|
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
|
|
import {
|
|
semanticObjectPropertyReferences,
|
|
semanticArrayPropertyReferences,
|
|
type JavaScriptSemanticObjectReference,
|
|
} from "./javascriptSemanticObjectReferences.js";
|
|
import { classifyNextAppRoutePath, routePath } from "./javascriptRoutePaths.js";
|
|
import type {
|
|
JavaScriptRouteCandidate,
|
|
JavaScriptRouteSegment,
|
|
JavaScriptRouteSourceFacts,
|
|
} from "./javascriptRouteFacts.js";
|
|
|
|
// Copy the parser interface into the JSON fact representation at its owner.
|
|
const range = (node: t.Node): JavaScriptRouteSegment["location"] => {
|
|
const value = sourceRange(node);
|
|
return { start: { ...value.start }, end: { ...value.end } };
|
|
};
|
|
|
|
type Mechanism = JavaScriptRouteCandidate["mechanism"];
|
|
type LegacyRoute = {
|
|
node: t.Node;
|
|
value: string;
|
|
mechanism: string;
|
|
/** Set when only the property name, not a router record, suggests a route. */
|
|
interpretation?: "candidate";
|
|
};
|
|
interface SegmentLink {
|
|
readonly previous: SegmentLink | null;
|
|
readonly segment: JavaScriptRouteSegment;
|
|
readonly inferred: boolean;
|
|
}
|
|
interface Ancestry {
|
|
readonly node: t.Node;
|
|
readonly previous: Ancestry | undefined;
|
|
}
|
|
interface Frame {
|
|
readonly node: t.Node;
|
|
readonly parent: SegmentLink | null | undefined;
|
|
readonly ancestry?: Ancestry;
|
|
readonly fallback?: { key: string; node: t.Node };
|
|
}
|
|
// Callable bodies execute separately. Recognized navigation owns structured
|
|
// destination data; unrelated containers retain their independent values.
|
|
const isRouteValueBoundary = (node: t.Node, structured: boolean): boolean =>
|
|
t.isFunction(node) ||
|
|
t.isClass(node) ||
|
|
(!structured && (t.isArrayExpression(node) || t.isObjectExpression(node)));
|
|
interface DynamicRouteValueOwner {
|
|
readonly node: t.Node;
|
|
readonly navigation: boolean;
|
|
readonly structured: boolean;
|
|
reported: boolean;
|
|
}
|
|
interface SelectedRouteTarget {
|
|
readonly values: t.Node[] | null;
|
|
readonly visited: WeakMap<
|
|
t.Node,
|
|
Map<SemanticAliasSelectionPath, Set<number>>
|
|
>;
|
|
}
|
|
interface SelectedRouteValue {
|
|
readonly kind: "value";
|
|
readonly node: t.Node;
|
|
readonly projection: SemanticAliasSelectionPath;
|
|
readonly depth: number;
|
|
readonly target: SelectedRouteTarget;
|
|
}
|
|
interface SelectedRouteLookup {
|
|
readonly kind: "lookup";
|
|
readonly node: t.Node;
|
|
readonly receiver: t.ObjectExpression | t.ArrayExpression;
|
|
readonly key: ReturnType<typeof semanticAliasSelectionChoices>[number];
|
|
readonly projection: SemanticAliasSelectionPath;
|
|
readonly depth: number;
|
|
readonly target: SelectedRouteTarget;
|
|
}
|
|
type RouteSelectionWork =
|
|
| SelectedRouteValue
|
|
| SelectedRouteLookup
|
|
| {
|
|
readonly kind: "finish";
|
|
readonly lookup: SelectedRouteLookup;
|
|
readonly key: string;
|
|
readonly values: t.Node[];
|
|
};
|
|
interface Fields {
|
|
readonly values: ReadonlyMap<string, t.ObjectProperty>;
|
|
readonly unknownKeys: boolean;
|
|
readonly dynamicNames: ReadonlySet<string>;
|
|
}
|
|
const structuredCalls = new Set([
|
|
"useRoutes",
|
|
"createBrowserRouter",
|
|
"createHashRouter",
|
|
"defineRoutes",
|
|
"createRoutes",
|
|
]);
|
|
const navigationCalls = new Set([
|
|
"push",
|
|
"replace",
|
|
"prefetch",
|
|
"navigate",
|
|
"href",
|
|
]);
|
|
const navigationReceivers = new Set([
|
|
"router",
|
|
"$router",
|
|
"history",
|
|
"location",
|
|
]);
|
|
const navigationRecognition = (
|
|
name: string,
|
|
receiver: string,
|
|
): boolean | undefined =>
|
|
navigationCalls.has(name)
|
|
? (name !== "push" && name !== "replace") ||
|
|
navigationReceivers.has(receiver)
|
|
: undefined;
|
|
const localNavigationFields = new Set(["pathname", "query", "search", "hash"]);
|
|
const nextNames = new Set(["TURBOPACK", "webpackChunk_N_E", "__NEXT_DATA__"]);
|
|
const isRouteChildKey = (key: string, typed: boolean): boolean =>
|
|
typed ? !key.startsWith("_") && !key.startsWith("$") : key === "children";
|
|
const isRouteSegmentKey = (key: string | undefined): boolean =>
|
|
key === "path" || key === "route" || key === "_$";
|
|
interface RouteRecordShape {
|
|
readonly typed: boolean;
|
|
readonly hasPath: boolean;
|
|
readonly hasChildren: boolean;
|
|
readonly hasView: boolean;
|
|
readonly hasIndex: boolean;
|
|
}
|
|
/** Read the static keys that identify a router record. */
|
|
const routeRecordShape = (node: t.ObjectExpression): RouteRecordShape => {
|
|
let typed = false,
|
|
hasPath = false,
|
|
hasChildren = false,
|
|
hasView = false,
|
|
hasIndex = false;
|
|
for (const property of node.properties) {
|
|
if (!t.isObjectProperty(property)) continue;
|
|
const key = semanticStaticPropertyName(property.key, property.computed);
|
|
typed ||= key === "_$";
|
|
hasPath ||= key === "path" || key === "route";
|
|
hasChildren ||= key === "children";
|
|
hasView ||= key === "element" || key === "Component" || key === "component";
|
|
hasIndex ||= key === "index";
|
|
}
|
|
return { typed, hasPath, hasChildren, hasView, hasIndex };
|
|
};
|
|
/** A path-bearing record that also names a view, children, or typed route. */
|
|
const isRouterRecord = (shape: RouteRecordShape): boolean =>
|
|
shape.typed || shape.hasView || shape.hasChildren;
|
|
/** Follow data-bearing edges, not references used only to select or describe it. */
|
|
const isNavigationDataEdge = (node: t.Node, parent: t.Node | null): boolean => {
|
|
if (parent === null) return true;
|
|
if (
|
|
t.isCallExpression(parent) ||
|
|
t.isOptionalCallExpression(parent) ||
|
|
t.isNewExpression(parent)
|
|
)
|
|
return node !== parent.callee;
|
|
if (t.isTaggedTemplateExpression(parent)) return node !== parent.tag;
|
|
if (t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent))
|
|
return false;
|
|
if (t.isConditionalExpression(parent)) {
|
|
if (node === parent.test) return false;
|
|
const truthiness = staticJavaScriptTruthiness(parent.test);
|
|
return (
|
|
truthiness === undefined ||
|
|
node === (truthiness ? parent.consequent : parent.alternate)
|
|
);
|
|
}
|
|
if (t.isUnaryExpression(parent)) return false;
|
|
if (t.isBinaryExpression(parent)) return parent.operator === "+";
|
|
if (t.isLogicalExpression(parent)) {
|
|
const left = unwrapJavaScriptExpression(parent.left).node;
|
|
const truthiness = staticJavaScriptTruthiness(left);
|
|
// A falsy && result cannot be a rooted path or structured route value.
|
|
if (parent.operator === "&&")
|
|
return node === parent.right && truthiness !== false;
|
|
if (parent.operator === "||")
|
|
return node === parent.left || truthiness !== true;
|
|
return (
|
|
node === parent.left || truthiness === undefined || t.isNullLiteral(left)
|
|
);
|
|
}
|
|
if (t.isSequenceExpression(parent)) return node === parent.expressions.at(-1);
|
|
if (t.isObjectProperty(parent)) return node === parent.value;
|
|
return true;
|
|
};
|
|
/** Shared syntax owner; both consumers borrow their existing AST walk and lexical index. */
|
|
export class JavaScriptRouteScan {
|
|
readonly #result: JavaScriptRouteSourceFacts = {
|
|
candidates: [],
|
|
unresolved: [],
|
|
capacity: null,
|
|
};
|
|
readonly #calls: {
|
|
node: t.CallExpression | t.OptionalCallExpression;
|
|
typed: boolean;
|
|
namedParent?: boolean;
|
|
}[] = [];
|
|
readonly #navigationValues = new WeakMap<
|
|
t.Node,
|
|
{
|
|
readonly value: t.Node | undefined;
|
|
readonly pathname: t.Node | undefined;
|
|
readonly occurrence: t.Node | undefined;
|
|
}
|
|
>();
|
|
readonly #automaticRoots = new Map<t.ObjectExpression, boolean>();
|
|
readonly #claimed = {
|
|
ordinary: new WeakSet<t.Node>(),
|
|
typed: new WeakSet<t.Node>(),
|
|
};
|
|
readonly #rejected = new WeakSet<t.Node>();
|
|
readonly #seen = new Set<string>();
|
|
readonly #literalPaths: { value: string; node: t.Node }[] = [];
|
|
readonly #propertyValues = new WeakSet<t.Node>();
|
|
readonly #dynamicValues = new WeakMap<t.Node, DynamicRouteValueOwner>();
|
|
readonly #expressionParents = new WeakMap<t.Node, t.Node>();
|
|
readonly #fieldsCache = new WeakMap<t.ObjectExpression, Fields>();
|
|
// One key scan per object, however many path or route properties it repeats.
|
|
readonly #shapeCache = new WeakMap<t.ObjectExpression, RouteRecordShape>();
|
|
readonly #routeParents = new WeakMap<
|
|
t.ObjectExpression,
|
|
t.ObjectExpression | null
|
|
>();
|
|
readonly #routeChildren: t.ObjectExpression[] = [];
|
|
#nextRuntime = false;
|
|
#factBytes: number;
|
|
#workBytes = 0;
|
|
|
|
private constructor(
|
|
private readonly index: JavaScriptBindingIndex,
|
|
readonly onLegacyRoute?: (fact: LegacyRoute) => void,
|
|
readonly factByteBudget = JAVASCRIPT_STATIC_FACT_BYTES,
|
|
) {
|
|
this.#factBytes = jsonValueByteLength({
|
|
candidates: [],
|
|
unresolved: [],
|
|
capacity: {
|
|
kind: "route-work-or-facts",
|
|
limit_bytes: factByteBudget,
|
|
accounted_fact_bytes: factByteBudget,
|
|
accounted_work_bytes: factByteBudget,
|
|
},
|
|
});
|
|
if (
|
|
!Number.isSafeInteger(factByteBudget) ||
|
|
factByteBudget < this.#factBytes
|
|
)
|
|
throw new RangeError(
|
|
"Route accounting budget must fit its coverage metadata",
|
|
);
|
|
}
|
|
|
|
/** Establish navigation data ownership before either adapter interprets syntax. */
|
|
static *createSteps(
|
|
root: t.Node,
|
|
index: JavaScriptBindingIndex,
|
|
onLegacyRoute?: (fact: LegacyRoute) => void,
|
|
factByteBudget = JAVASCRIPT_STATIC_FACT_BYTES,
|
|
): Generator<void, JavaScriptRouteScan> {
|
|
const scan = new JavaScriptRouteScan(index, onLegacyRoute, factByteBudget);
|
|
yield* scan.#prepareNavigationSteps(root);
|
|
return scan;
|
|
}
|
|
|
|
*#prepareNavigationSteps(root: t.Node): Generator<void> {
|
|
const requests: { node: t.Node; site: t.Node }[] = [];
|
|
const remember = (node: t.Node, site: t.Node): void => {
|
|
const value = unwrapJavaScriptExpression(node).node;
|
|
if (stringValue(value) !== undefined || isRouteValueBoundary(value, true))
|
|
return;
|
|
if (this.#queue(node)) requests.push({ node, site });
|
|
};
|
|
yield* traverseJavaScriptAstSteps(root, {
|
|
skip: () => this.#result.capacity !== null,
|
|
enter: (node) => {
|
|
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
|
const parts = calleeName(node.callee).split(".");
|
|
if (navigationRecognition(parts.at(-1)!, parts.at(-2) ?? "") !== true)
|
|
return;
|
|
const first = node.arguments[0];
|
|
if (
|
|
first !== undefined &&
|
|
!t.isSpreadElement(first) &&
|
|
!t.isArgumentPlaceholder(first)
|
|
)
|
|
remember(first, node);
|
|
} else if (
|
|
t.isObjectProperty(node) &&
|
|
semanticStaticPropertyName(node.key, node.computed) === "href"
|
|
)
|
|
remember(node.value, node);
|
|
},
|
|
});
|
|
if (requests.length === 0 || this.#result.capacity !== null) return;
|
|
yield* this.index.indexMemberMutationFactsSteps();
|
|
for (const { node, site } of requests) {
|
|
yield;
|
|
if (this.#result.capacity !== null) return;
|
|
const value = this.#resolve(node, site, this.index);
|
|
const field =
|
|
value !== undefined && t.isObjectExpression(value)
|
|
? this.#fields(value).values.get("pathname")?.value
|
|
: undefined;
|
|
const pathname =
|
|
field === undefined
|
|
? undefined
|
|
: this.#resolve(field, site, this.index);
|
|
this.#navigationValues.set(node, {
|
|
value,
|
|
pathname,
|
|
occurrence:
|
|
value !== unwrapJavaScriptExpression(node).node
|
|
? node
|
|
: field !== undefined &&
|
|
pathname !== unwrapJavaScriptExpression(field).node
|
|
? field
|
|
: undefined,
|
|
});
|
|
}
|
|
// Structural data references share one bounded ownership closure. A source
|
|
// initializer can serve many use sites; freshness remains per request above.
|
|
const pending: t.Node[] = [];
|
|
const seen = new WeakSet<t.Node>();
|
|
const nonValueReferences = new WeakSet<t.Node>();
|
|
const admit = (node: t.Node): void => {
|
|
if (
|
|
seen.has(node) ||
|
|
isRouteValueBoundary(node, true) ||
|
|
!this.#queue(node)
|
|
)
|
|
return;
|
|
seen.add(node);
|
|
pending.push(node);
|
|
};
|
|
for (const request of requests) admit(request.node);
|
|
while (pending.length > 0 && this.#result.capacity === null) {
|
|
yield;
|
|
const value = pending.pop()!;
|
|
const owner: DynamicRouteValueOwner = {
|
|
node: value,
|
|
navigation: true,
|
|
structured: true,
|
|
reported: true,
|
|
};
|
|
yield* traverseJavaScriptAstSteps(value, {
|
|
skip: (node) =>
|
|
this.#result.capacity !== null ||
|
|
isRouteValueBoundary(node, true) ||
|
|
seen.has(node),
|
|
enter: (node, parent) => {
|
|
if (node !== value) {
|
|
if (!this.#queue(node)) return;
|
|
seen.add(node);
|
|
}
|
|
this.#dynamicValues.set(node, owner);
|
|
let overwritten = false;
|
|
if (
|
|
t.isObjectProperty(node) &&
|
|
!isObjectPrototypeSetter(node) &&
|
|
t.isObjectExpression(parent)
|
|
) {
|
|
const key = semanticStaticPropertyKey(node.key, node.computed);
|
|
if (key !== null) {
|
|
const fields = this.#fields(parent);
|
|
const effective = fields.values.get(key);
|
|
// Unknown spreads/keys may preserve earlier data. Only a known
|
|
// final slot or method proves that this reference was discarded.
|
|
overwritten =
|
|
fields.dynamicNames.has(key) ||
|
|
(effective !== undefined && effective !== node);
|
|
}
|
|
}
|
|
const nonValue =
|
|
overwritten ||
|
|
t.isTSType(node) ||
|
|
!isNavigationDataEdge(node, parent) ||
|
|
(parent !== null && nonValueReferences.has(parent));
|
|
if (nonValue) nonValueReferences.add(node);
|
|
if (
|
|
!nonValue &&
|
|
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
|
|
) {
|
|
for (const selected of this.#memberValues(node)) admit(selected);
|
|
}
|
|
if (
|
|
nonValue ||
|
|
!t.isIdentifier(node) ||
|
|
(parent !== null && !t.isReferenced(node, parent))
|
|
)
|
|
return;
|
|
const declared = this.index.bindingForReferenceAt(node);
|
|
if (declared === undefined) return;
|
|
for (const sources of [
|
|
declared.initializers,
|
|
declared.referenceInitializers,
|
|
])
|
|
for (const source of sources) if (!this.#queue(source.node)) return;
|
|
// Different reads may select different values; unresolved order
|
|
// retains its possible sources instead of becoming absence.
|
|
const binding = semanticBindingAtRead(
|
|
declared,
|
|
node,
|
|
this.index.bindings.parentsByNode,
|
|
).binding;
|
|
for (const initializers of [
|
|
binding.initializers,
|
|
binding.referenceInitializers,
|
|
])
|
|
for (const initializer of initializers) {
|
|
if (
|
|
semanticReferenceSourcesInactive(
|
|
initializer,
|
|
this.index.bindings,
|
|
)
|
|
)
|
|
continue;
|
|
for (const selected of this.#initializerValues(initializer))
|
|
admit(selected);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
inspect(node: t.Node, parent: t.Node | null): void {
|
|
const expressionParent =
|
|
parent === null ? null : (this.#expressionParents.get(parent) ?? parent);
|
|
if (
|
|
expressionParent !== null &&
|
|
javaScriptExpressionWrapperTarget(node) !== undefined &&
|
|
this.#queue(node)
|
|
)
|
|
this.#expressionParents.set(node, expressionParent);
|
|
const owner = parent === null ? undefined : this.#dynamicValues.get(parent);
|
|
if (
|
|
owner !== undefined &&
|
|
!isRouteValueBoundary(node, owner.structured) &&
|
|
!this.#dynamicValues.has(node) &&
|
|
this.#queue(node)
|
|
)
|
|
this.#dynamicValues.set(node, owner);
|
|
// Legacy projections receive the same owned literal observations even when the new facet is full.
|
|
if (t.isObjectProperty(node)) {
|
|
this.#property(node, parent);
|
|
return;
|
|
}
|
|
if (
|
|
t.isCallExpression(node) ||
|
|
t.isOptionalCallExpression(node) ||
|
|
t.isNewExpression(node)
|
|
) {
|
|
this.#call(node);
|
|
return;
|
|
}
|
|
if (this.#result.capacity !== null) return;
|
|
if (
|
|
t.isAssignmentExpression(node) &&
|
|
t.isMemberExpression(node.left) &&
|
|
isRouteSegmentKey(
|
|
semanticStaticPropertyName(node.left.property, node.left.computed),
|
|
)
|
|
) {
|
|
this.#propertyValues.add(node.right);
|
|
if (stringValue(node.right) === undefined)
|
|
this.#ownDynamicValue(node.right, false);
|
|
}
|
|
if (!this.#nextRuntime && t.isIdentifier(node) && nextNames.has(node.name))
|
|
this.#nextRuntime = true;
|
|
if (t.isStringLiteral(node) || t.isTemplateLiteral(node)) {
|
|
const dynamic = this.#dynamicValues.get(node);
|
|
if (dynamic !== undefined) {
|
|
const fragment =
|
|
stringValue(node) ??
|
|
(t.isTemplateLiteral(node)
|
|
? (node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw)
|
|
: undefined);
|
|
// A rooted fragment establishes a dynamic navigation observation, not
|
|
// a whole route. Unrelated array.push(value) calls add no route noise.
|
|
if (
|
|
dynamic.navigation &&
|
|
!dynamic.reported &&
|
|
fragment !== undefined &&
|
|
routePath(fragment, { navigation: true }) !== undefined
|
|
) {
|
|
dynamic.reported = true;
|
|
this.#unknown(
|
|
"navigation-target",
|
|
dynamic.node,
|
|
"The navigation target is dynamic; literal fragments do not establish a complete path.",
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
const value = stringValue(node);
|
|
if (value === undefined) return;
|
|
const next = classifyNextAppRoutePath(value);
|
|
if (
|
|
(t.isBinaryExpression(expressionParent) &&
|
|
expressionParent.operator === "+") ||
|
|
(t.isTemplateLiteral(expressionParent) &&
|
|
expressionParent.expressions.length > 0)
|
|
) {
|
|
if (next !== undefined)
|
|
this.#unknown(
|
|
"next-app-chunk",
|
|
expressionParent!,
|
|
"The module filename is part of a computed expression; a complete page path remains unknown.",
|
|
);
|
|
return;
|
|
}
|
|
if (next?.kind === "unknown") {
|
|
this.#unknown("next-app-chunk", node, next.reason);
|
|
return;
|
|
}
|
|
if (next?.kind === "known")
|
|
this.#emit(
|
|
next.path,
|
|
"next-app-chunk",
|
|
[{ value, location: range(node), origin: "next-app-path" }],
|
|
node,
|
|
false,
|
|
);
|
|
if (
|
|
!this.#propertyValues.has(node) &&
|
|
routePath(value) !== undefined &&
|
|
this.#queue(node, value)
|
|
)
|
|
this.#literalPaths.push({ value, node });
|
|
} else if (t.isObjectExpression(node)) this.#object(node);
|
|
}
|
|
|
|
#property(node: t.ObjectProperty, parent: t.Node | null): void {
|
|
const name = semanticStaticPropertyName(node.key, node.computed);
|
|
if (name === "path" || name === "route") {
|
|
const value = stringValue(node.value);
|
|
if (value !== undefined)
|
|
this.onLegacyRoute?.({
|
|
node,
|
|
value,
|
|
mechanism: `property:${name}`,
|
|
// A path key alone also names data records such as patch operations.
|
|
...(t.isObjectExpression(parent) &&
|
|
isRouterRecord(this.#shape(parent))
|
|
? {}
|
|
: { interpretation: "candidate" }),
|
|
});
|
|
}
|
|
if (this.#result.capacity === null && isRouteSegmentKey(name))
|
|
this.#propertyValues.add(unwrapJavaScriptExpression(node.value).node);
|
|
if (
|
|
this.#result.capacity === null &&
|
|
isRouteSegmentKey(name) &&
|
|
!this.#dynamicValues.has(node) &&
|
|
!this.#dynamicValues.has(node.value) &&
|
|
stringValue(node.value) === undefined
|
|
)
|
|
this.#ownDynamicValue(node.value, false);
|
|
if (
|
|
this.#result.capacity === null &&
|
|
name === "href" &&
|
|
!this.#dynamicValues.has(node)
|
|
)
|
|
this.#navigation(node.value);
|
|
}
|
|
|
|
#call(
|
|
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
|
|
): void {
|
|
const fullName = calleeName(node.callee),
|
|
parts = fullName.split("."),
|
|
name = parts.at(-1)!;
|
|
const first = node.arguments[0];
|
|
const legacy = STATIC_ROUTE_CALL_NAMES.some(
|
|
(candidate) => candidate === name,
|
|
);
|
|
if (legacy && first !== undefined) {
|
|
const value = stringValue(first);
|
|
if (value !== undefined)
|
|
this.onLegacyRoute?.({ node, value, mechanism: `call:${fullName}` });
|
|
}
|
|
if (this.#result.capacity !== null || t.isNewExpression(node)) return;
|
|
if (structuredCalls.has(name)) {
|
|
if (this.#queue(node))
|
|
this.#calls.push({
|
|
node,
|
|
typed: name === "defineRoutes" || name === "createRoutes",
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
first === undefined ||
|
|
t.isSpreadElement(first) ||
|
|
t.isArgumentPlaceholder(first)
|
|
)
|
|
return;
|
|
const recognized = navigationRecognition(name, parts.at(-2) ?? "");
|
|
if (recognized === true) this.#navigation(first);
|
|
else if (recognized === false) {
|
|
// Method spelling alone does not make array/string data a destination.
|
|
const value = this.#resolve(first, undefined, this.index);
|
|
if (value !== undefined) this.#propertyValues.add(value);
|
|
}
|
|
if (name === "addRoute" && node.arguments.length === 2) {
|
|
// This overload can name a parent rather than a URL. Resolve its record
|
|
// only after the shared binding index is complete.
|
|
this.#ownDynamicValue(first, false);
|
|
this.#propertyValues.add(unwrapJavaScriptExpression(first).node);
|
|
if (this.#queue(node))
|
|
this.#calls.push({ node, typed: false, namedParent: true });
|
|
return;
|
|
}
|
|
if (name === "route" || name === "addRoute") this.#registeredPath(first);
|
|
}
|
|
|
|
#registeredPath(first: t.Node): void {
|
|
this.#propertyValues.add(unwrapJavaScriptExpression(first).node);
|
|
if (stringValue(first) === undefined) this.#ownDynamicValue(first, false);
|
|
const value = stringValue(first),
|
|
path =
|
|
value === undefined ? undefined : routePath(value, { relative: true });
|
|
if (path !== undefined)
|
|
this.#emit(
|
|
path,
|
|
"router-call",
|
|
[{ value: value!, location: range(first), origin: "literal" }],
|
|
first,
|
|
!value!.startsWith("/"),
|
|
);
|
|
else
|
|
this.#unknown(
|
|
"router-call",
|
|
first,
|
|
"The registered path is not a supported literal.",
|
|
);
|
|
}
|
|
|
|
#shape(node: t.ObjectExpression): RouteRecordShape {
|
|
const cached = this.#shapeCache.get(node);
|
|
if (cached !== undefined) return cached;
|
|
const shape = routeRecordShape(node);
|
|
this.#shapeCache.set(node, shape);
|
|
return shape;
|
|
}
|
|
|
|
#object(node: t.ObjectExpression): void {
|
|
// Structured navigation owns these data containers before either automatic
|
|
// roots or parent edges are discovered. Callable bodies are already outside
|
|
// that ownership at the traversal boundary.
|
|
if (this.#dynamicValues.has(node) || this.#result.capacity !== null) return;
|
|
const { typed, hasPath, hasChildren, hasView, hasIndex } =
|
|
this.#shape(node);
|
|
// Only router children establish path ancestry. Ordinary object wrappers
|
|
// can contain routes without contributing URL segments.
|
|
if (!typed && hasChildren && this.#queue(node))
|
|
this.#routeChildren.push(node);
|
|
if (
|
|
(typed || (hasView && (hasPath || hasChildren || hasIndex))) &&
|
|
this.#queue(node)
|
|
)
|
|
this.#automaticRoots.set(node, typed);
|
|
}
|
|
|
|
*finishSteps(): Generator<void, JavaScriptRouteSourceFacts> {
|
|
const index = this.index;
|
|
if (this.#result.capacity !== null) return this.#result;
|
|
if (
|
|
this.#calls.length > 0 ||
|
|
this.#automaticRoots.size > 0 ||
|
|
this.#routeChildren.length > 0
|
|
)
|
|
yield* index.indexMemberMutationFactsSteps();
|
|
yield* this.#indexRouteParents(index);
|
|
for (const call of this.#calls) {
|
|
if (this.#result.capacity !== null) break;
|
|
yield;
|
|
const first = call.node.arguments[call.namedParent === true ? 1 : 0];
|
|
if (
|
|
first === undefined ||
|
|
t.isSpreadElement(first) ||
|
|
t.isArgumentPlaceholder(first)
|
|
) {
|
|
this.#unknown(
|
|
call.typed ? "typed-route-tree" : "nested-route-object",
|
|
call.node,
|
|
"The route configuration is unavailable.",
|
|
);
|
|
continue;
|
|
}
|
|
if (call.namedParent === true) {
|
|
const record = this.#resolve(first, call.node, index);
|
|
if (record !== undefined && t.isFunction(record)) {
|
|
this.#registeredPath(call.node.arguments[0]!);
|
|
} else {
|
|
this.#claimConfiguration(first, index, false);
|
|
this.#unknown(
|
|
"router-call",
|
|
call.node,
|
|
"The named parent route does not establish a static URL ancestry.",
|
|
);
|
|
yield* this.#walk(first, call.node, false, index, {
|
|
unknownParent: true,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
this.#claimConfiguration(first, index, call.typed);
|
|
yield* this.#walk(first, call.node, call.typed, index);
|
|
}
|
|
for (const [node, typed] of this.#automaticRoots) {
|
|
if (this.#result.capacity !== null) break;
|
|
yield;
|
|
const claimed = this.#claimed[typed ? "typed" : "ordinary"];
|
|
const ancestry = new Set<t.ObjectExpression>([node]);
|
|
let root = node,
|
|
owned = claimed.has(root) || this.#rejected.has(root);
|
|
while (!owned && !typed) {
|
|
const parent = this.#routeParents.get(root);
|
|
if (parent === undefined) break;
|
|
if (parent === null || ancestry.has(parent)) {
|
|
this.#unknown(
|
|
"nested-route-object",
|
|
root,
|
|
"Multiple or cyclic parents leave the inferred route ancestry unknown.",
|
|
);
|
|
owned = true;
|
|
break;
|
|
}
|
|
ancestry.add(parent);
|
|
root = parent;
|
|
owned = claimed.has(root) || this.#rejected.has(root);
|
|
}
|
|
if (!owned) {
|
|
this.#unknown(
|
|
typed ? "typed-route-tree" : "nested-route-object",
|
|
root,
|
|
"The routing root is inferred from configuration syntax without a bound route-builder call.",
|
|
);
|
|
this.#claimConfiguration(root, index, typed);
|
|
yield* this.#walk(root, root, typed, index, { inferredRoot: true });
|
|
}
|
|
}
|
|
const roots = new Set(
|
|
this.#result.candidates
|
|
.map(({ path }) => path.split("/")[1])
|
|
.filter(Boolean),
|
|
);
|
|
for (const literal of this.#literalPaths) {
|
|
if (this.#result.capacity !== null) break;
|
|
yield;
|
|
// Alias initializers may have been visited before their registration.
|
|
// Decide generic-constant admission only after all specific owners finish.
|
|
if (this.#propertyValues.has(literal.node)) continue;
|
|
const path = routePath(literal.value, { navigation: true });
|
|
if (
|
|
path === undefined ||
|
|
/^\/(?:_next|api|assets|static)(?:\/|$)/u.test(path)
|
|
)
|
|
continue;
|
|
// These remain inferred candidates. The inventory owner additionally binds them to app roots across sources.
|
|
if (this.#nextRuntime || roots.has(path.split("/")[1]!))
|
|
this.#emit(
|
|
path,
|
|
"route-string-constant",
|
|
[
|
|
{
|
|
value: literal.value,
|
|
location: range(literal.node),
|
|
origin: "literal",
|
|
},
|
|
],
|
|
literal.node,
|
|
true,
|
|
);
|
|
}
|
|
return this.#result;
|
|
}
|
|
|
|
*#indexRouteParents(index: JavaScriptBindingIndex): Generator<void> {
|
|
for (const parent of this.#routeChildren) {
|
|
const value = this.#fields(parent).values.get("children")?.value;
|
|
if (value === undefined) continue;
|
|
const pending: t.Node[] = [value],
|
|
seen = new Set<t.Node>();
|
|
while (pending.length > 0 && this.#result.capacity === null) {
|
|
yield;
|
|
const raw = pending.pop()!,
|
|
node = this.#resolve(raw, parent, index);
|
|
if (
|
|
node === undefined ||
|
|
(!t.isArrayExpression(node) && !t.isObjectExpression(node))
|
|
) {
|
|
if (this.#claimConfiguration(raw, index, false, true))
|
|
this.#unknown(
|
|
"nested-route-object",
|
|
raw,
|
|
"The child configuration cannot establish stable route ancestry.",
|
|
);
|
|
continue;
|
|
}
|
|
if (seen.has(node)) continue;
|
|
seen.add(node);
|
|
if (t.isArrayExpression(node)) {
|
|
for (const child of node.elements)
|
|
if (child !== null && this.#queue(child)) pending.push(child);
|
|
} else {
|
|
const previous = this.#routeParents.get(node);
|
|
this.#routeParents.set(
|
|
node,
|
|
previous === undefined || previous === parent ? parent : null,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#ownDynamicValue(
|
|
node: t.Node,
|
|
navigation: boolean,
|
|
reported = false,
|
|
structured = false,
|
|
): void {
|
|
// Leaf references have no descendants to suppress.
|
|
if (
|
|
!t.isIdentifier(node) &&
|
|
!isRouteValueBoundary(node, structured) &&
|
|
this.#queue(node)
|
|
)
|
|
this.#dynamicValues.set(node, { node, navigation, reported, structured });
|
|
}
|
|
|
|
#navigation(node: t.Node): void {
|
|
const prepared = this.#navigationValues.get(node);
|
|
const unwrapped =
|
|
prepared === undefined
|
|
? unwrapJavaScriptExpression(node).node
|
|
: prepared.value;
|
|
if (unwrapped === undefined) {
|
|
this.#unknown(
|
|
"navigation-target",
|
|
node,
|
|
"The navigation destination may be dynamic, mutated, projected or cyclic.",
|
|
);
|
|
return;
|
|
}
|
|
const pathnameObject = t.isObjectExpression(unwrapped);
|
|
let destination = unwrapped;
|
|
if (pathnameObject) {
|
|
const fields = this.#fields(unwrapped);
|
|
let local = !fields.unknownKeys && fields.dynamicNames.size === 0;
|
|
for (const key of fields.values.keys())
|
|
if (!localNavigationFields.has(key)) {
|
|
local = false;
|
|
break;
|
|
}
|
|
const pathname = fields.values.get("pathname");
|
|
if (local && pathname !== undefined)
|
|
destination = prepared?.pathname ?? pathname.value;
|
|
// The destination owns its query/search/hash data, including href-like
|
|
// properties. Callable bodies remain independent syntax contexts.
|
|
this.#ownDynamicValue(node, true, true, true);
|
|
}
|
|
destination = unwrapJavaScriptExpression(destination).node;
|
|
const value = stringValue(destination);
|
|
if (value === undefined) {
|
|
this.#unknown(
|
|
"navigation-target",
|
|
node,
|
|
"The recognized navigation destination is not a supported literal.",
|
|
);
|
|
if (!pathnameObject) this.#ownDynamicValue(node, true, true, true);
|
|
return;
|
|
}
|
|
const path = routePath(value, { navigation: !pathnameObject });
|
|
if (path !== undefined)
|
|
this.#emit(
|
|
path,
|
|
"navigation-target",
|
|
[{ value, location: range(destination), origin: "literal" }],
|
|
prepared?.occurrence ?? destination,
|
|
true,
|
|
);
|
|
else if (
|
|
pathnameObject ||
|
|
(!value.startsWith("//") && !/^[a-z][a-z0-9+.-]*:/iu.test(value))
|
|
)
|
|
this.#unknown(
|
|
"navigation-target",
|
|
node,
|
|
"The relative or unsupported navigation literal cannot establish a rooted path without runtime context.",
|
|
);
|
|
}
|
|
|
|
#fields(node: t.ObjectExpression): Fields {
|
|
const cached = this.#fieldsCache.get(node);
|
|
if (cached !== undefined) return cached;
|
|
const values = new Map<string, t.ObjectProperty>(),
|
|
dynamicNames = new Set<string>();
|
|
let unknownKeys = false;
|
|
for (const property of node.properties) {
|
|
if (!this.#queue(property)) {
|
|
unknownKeys = true;
|
|
break;
|
|
}
|
|
if (t.isSpreadElement(property)) {
|
|
values.clear();
|
|
dynamicNames.clear();
|
|
unknownKeys = true;
|
|
continue;
|
|
}
|
|
if (t.isObjectProperty(property) && isObjectPrototypeSetter(property)) {
|
|
// Inheritance may supply an unresolved lookup, but cannot replace an
|
|
// explicit own slot, including a computed or shorthand __proto__.
|
|
unknownKeys = true;
|
|
continue;
|
|
}
|
|
const key = semanticStaticPropertyKey(property.key, property.computed);
|
|
if (key === null) {
|
|
values.clear();
|
|
dynamicNames.clear();
|
|
unknownKeys = true;
|
|
} else if (t.isObjectProperty(property)) {
|
|
values.set(key, property);
|
|
dynamicNames.delete(key);
|
|
} else {
|
|
values.delete(key);
|
|
dynamicNames.add(key);
|
|
}
|
|
}
|
|
const parsed = { values, dynamicNames, unknownKeys };
|
|
this.#fieldsCache.set(node, parsed);
|
|
return parsed;
|
|
}
|
|
|
|
#resolve(
|
|
node: t.Node,
|
|
site: t.Node | undefined,
|
|
index: JavaScriptBindingIndex,
|
|
): t.Node | undefined {
|
|
const seen = new Set<t.Node>();
|
|
for (;;) {
|
|
node = unwrapJavaScriptExpression(node).node;
|
|
if (!t.isIdentifier(node)) return node;
|
|
if (seen.has(node) || !this.#queue(node)) return;
|
|
seen.add(node);
|
|
const initializer = semanticStableInitializer(
|
|
index.bindingForReferenceAt(node),
|
|
index.bindings,
|
|
);
|
|
if (
|
|
initializer === undefined ||
|
|
initializer.projection.length !== 0 ||
|
|
(site !== undefined && index.referenceMayHaveChangedAt(site, node))
|
|
)
|
|
return;
|
|
node = initializer.node;
|
|
}
|
|
}
|
|
|
|
/** Select members without admitting their entire receiver as navigation data. */
|
|
*#memberValues(
|
|
node: t.MemberExpression | t.OptionalMemberExpression,
|
|
mechanism: Mechanism = "navigation-target",
|
|
): Generator<t.Node> {
|
|
const chain = semanticMemberChain(node, (part) => this.#queue(part));
|
|
if (chain === undefined || !t.isIdentifier(chain.root)) return;
|
|
const binding = this.index.bindingForReferenceAt(chain.root);
|
|
if (binding === undefined) return;
|
|
// Admit possible source expansion before the semantic owner allocates its
|
|
// selected-source list. Repeated reads share cached object field parsing.
|
|
for (const sources of [binding.initializers, binding.referenceInitializers])
|
|
for (const source of sources) if (!this.#queue(source.node)) return;
|
|
for (const write of binding.memberWrites) {
|
|
if (!this.#queue(write.node ?? node)) return;
|
|
for (const source of write.assignedSources ?? [])
|
|
if (!this.#queue(source.node)) return;
|
|
}
|
|
// Value flow excludes proven replacements; lifetime identity eligibility
|
|
// would keep them and incorrectly hide otherwise independent routes.
|
|
const sources = semanticProjectedSourcesAtRead(
|
|
binding,
|
|
chain.root,
|
|
chain.members,
|
|
this.index.bindings,
|
|
false,
|
|
node,
|
|
);
|
|
for (const source of sources ?? [])
|
|
yield* this.#selectedValues(source.node, source.path, mechanism);
|
|
}
|
|
|
|
/** Follow copied data slots without claiming their excluded source siblings. */
|
|
*#initializerValues(
|
|
initializer: JavaScriptSemanticBindingState["referenceInitializers"][number],
|
|
mechanism: Mechanism = "navigation-target",
|
|
): Generator<t.Node> {
|
|
const projection = semanticCopiedReferencePath(initializer, [], "escape");
|
|
if (projection === null) return;
|
|
yield* this.#selectedValues(initializer.node, projection, mechanism);
|
|
}
|
|
|
|
/** Member reads and copied initializers share the same selected-slot walk. */
|
|
*#selectedValues(
|
|
source: t.Node,
|
|
projection: SemanticAliasSelectionPath,
|
|
mechanism: Mechanism,
|
|
): Generator<t.Node> {
|
|
if (!this.#queue(source)) return;
|
|
const pending: RouteSelectionWork[] = [
|
|
{
|
|
kind: "value",
|
|
node: source,
|
|
projection,
|
|
depth: 0,
|
|
target: { values: null, visited: new WeakMap() },
|
|
},
|
|
];
|
|
// A lookup stays active only while evaluating its returned references.
|
|
// The caller's remaining members run after that value has been obtained.
|
|
const active = new WeakMap<t.Node, Set<string>>();
|
|
while (pending.length > 0 && this.#result.capacity === null) {
|
|
const work = pending.pop()!;
|
|
if (work.kind === "finish") {
|
|
const { lookup } = work;
|
|
active.get(lookup.receiver)?.delete(work.key);
|
|
for (const value of work.values)
|
|
if (
|
|
!this.#retainSelection(
|
|
pending,
|
|
value,
|
|
lookup.projection,
|
|
lookup.depth + 1,
|
|
lookup.target,
|
|
)
|
|
)
|
|
return;
|
|
continue;
|
|
}
|
|
const { node, projection, depth, target } = work;
|
|
if (work.kind === "value") {
|
|
const paths =
|
|
target.visited.get(node) ??
|
|
new Map<SemanticAliasSelectionPath, Set<number>>();
|
|
const depths = paths.get(projection) ?? new Set<number>();
|
|
if (depths.has(depth)) continue;
|
|
depths.add(depth);
|
|
paths.set(projection, depths);
|
|
target.visited.set(node, paths);
|
|
if (depth === projection.length) {
|
|
if (target.values === null) yield node;
|
|
else target.values.push(node);
|
|
continue;
|
|
}
|
|
const selected = this.#resolve(node, undefined, this.index);
|
|
if (selected === undefined) continue;
|
|
if (!t.isObjectExpression(selected) && !t.isArrayExpression(selected)) {
|
|
for (const branch of this.#selectionBranches(selected))
|
|
if (
|
|
!this.#retainSelection(pending, branch, projection, depth, target)
|
|
)
|
|
return;
|
|
continue;
|
|
}
|
|
for (const key of semanticAliasSelectionChoices(projection[depth]!)) {
|
|
if (!this.#queue(node)) return;
|
|
pending.push({
|
|
kind: "lookup",
|
|
node,
|
|
receiver: selected,
|
|
key,
|
|
projection,
|
|
depth,
|
|
target,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const key = JSON.stringify(work.key);
|
|
const keys = active.get(work.receiver) ?? new Set<string>();
|
|
if (keys.has(key)) {
|
|
this.#unknown(
|
|
mechanism,
|
|
node,
|
|
"A cyclic selected-value lookup cannot establish its possible data sources.",
|
|
);
|
|
continue;
|
|
}
|
|
keys.add(key);
|
|
active.set(work.receiver, keys);
|
|
const values: t.Node[] = [];
|
|
const returned: SelectedRouteTarget = {
|
|
values,
|
|
visited: new WeakMap(),
|
|
};
|
|
pending.push({ kind: "finish", lookup: work, key, values });
|
|
for (const reference of this.#selectedReferences(work.receiver, work.key))
|
|
if (
|
|
!this.#retainSelection(
|
|
pending,
|
|
reference.node,
|
|
reference.path,
|
|
0,
|
|
returned,
|
|
)
|
|
)
|
|
return;
|
|
}
|
|
}
|
|
|
|
#selectionBranches(node: t.Node): readonly t.Node[] {
|
|
const branches = t.isConditionalExpression(node)
|
|
? [node.consequent, node.alternate]
|
|
: t.isLogicalExpression(node)
|
|
? [node.left, node.right]
|
|
: t.isSequenceExpression(node)
|
|
? node.expressions
|
|
: t.isAssignmentExpression(node)
|
|
? [node.right]
|
|
: [];
|
|
return branches.filter((branch) => isNavigationDataEdge(branch, node));
|
|
}
|
|
|
|
#selectedReferences(
|
|
node: t.ObjectExpression | t.ArrayExpression,
|
|
key: SelectedRouteLookup["key"],
|
|
): readonly JavaScriptSemanticObjectReference[] {
|
|
// Admit the literal before canonical owners allocate their indexed results.
|
|
if (t.isObjectExpression(node)) {
|
|
this.#fields(node);
|
|
return this.#result.capacity === null
|
|
? semanticObjectPropertyReferences(node, [key], this.index.bindings)
|
|
: [];
|
|
}
|
|
for (const element of node.elements)
|
|
if (element !== null && !this.#queue(element)) return [];
|
|
return semanticArrayPropertyReferences(node, [key]);
|
|
}
|
|
|
|
#retainSelection(
|
|
pending: RouteSelectionWork[],
|
|
node: t.Node,
|
|
projection: SemanticAliasSelectionPath,
|
|
depth: number,
|
|
target: SelectedRouteTarget,
|
|
): boolean {
|
|
if (!this.#queue(node)) return false;
|
|
pending.push({ kind: "value", node, projection, depth, target });
|
|
return true;
|
|
}
|
|
|
|
/** Ownership is per interpretation; return whether inferred roots were found. */
|
|
#claimConfiguration(
|
|
root: t.Node,
|
|
index: JavaScriptBindingIndex,
|
|
typed: boolean,
|
|
rejected = false,
|
|
): boolean {
|
|
let inferredRoots = false;
|
|
const pending: { node: t.Node; typed: boolean }[] = [],
|
|
visited = rejected
|
|
? { ordinary: new WeakSet<t.Node>(), typed: new WeakSet<t.Node>() }
|
|
: this.#claimed;
|
|
const admit = (
|
|
raw: t.Node | null | undefined,
|
|
interpretation: boolean,
|
|
): void => {
|
|
if (raw == null) return;
|
|
const node = unwrapJavaScriptExpression(raw).node;
|
|
if (
|
|
!(
|
|
t.isObjectExpression(node) ||
|
|
t.isArrayExpression(node) ||
|
|
t.isIdentifier(node) ||
|
|
t.isMemberExpression(node) ||
|
|
t.isOptionalMemberExpression(node) ||
|
|
t.isConditionalExpression(node) ||
|
|
t.isLogicalExpression(node) ||
|
|
t.isSequenceExpression(node) ||
|
|
t.isAssignmentExpression(node) ||
|
|
t.isSpreadElement(node) ||
|
|
t.isCallExpression(node) ||
|
|
t.isOptionalCallExpression(node)
|
|
)
|
|
)
|
|
return;
|
|
// Invalid selected data must not reappear through an independent syntax
|
|
// interpretation. Reuse the classifier that discovered those roots.
|
|
if (
|
|
rejected &&
|
|
t.isObjectExpression(node) &&
|
|
this.#automaticRoots.get(node) === true
|
|
)
|
|
interpretation = true;
|
|
const claimed = visited[interpretation ? "typed" : "ordinary"];
|
|
if (!claimed.has(node) && this.#queue(node)) {
|
|
claimed.add(node);
|
|
if (t.isObjectExpression(node) && this.#automaticRoots.has(node))
|
|
inferredRoots = true;
|
|
if (rejected) this.#rejected.add(node);
|
|
pending.push({ node, typed: interpretation });
|
|
}
|
|
};
|
|
admit(root, typed);
|
|
while (pending.length > 0 && this.#result.capacity === null) {
|
|
const { node, typed } = pending.pop()!;
|
|
if (t.isIdentifier(node)) {
|
|
const binding = index.bindingForReferenceAt(node);
|
|
// All source initializers belong to this binding, even when mutation,
|
|
// conditional execution or projection prevents trusting a current value.
|
|
for (const sources of [
|
|
binding?.initializers ?? [],
|
|
binding?.referenceInitializers ?? [],
|
|
])
|
|
for (const value of sources)
|
|
for (const selected of this.#initializerValues(
|
|
value,
|
|
typed ? "typed-route-tree" : "nested-route-object",
|
|
))
|
|
admit(selected, typed);
|
|
} else if (t.isObjectExpression(node)) {
|
|
for (const property of node.properties) {
|
|
if (t.isObjectProperty(property)) {
|
|
const key = semanticStaticPropertyKey(
|
|
property.key,
|
|
property.computed,
|
|
);
|
|
// Owning a route does not own the component/loader inputs it uses.
|
|
// Follow the same route relationships consumed by the route walker.
|
|
if (key === null || isRouteChildKey(key, typed))
|
|
admit(property.value, typed);
|
|
} else if (t.isSpreadElement(property))
|
|
admit(property.argument, typed);
|
|
}
|
|
} else if (t.isArrayExpression(node))
|
|
for (const child of node.elements) admit(child, typed);
|
|
else if (
|
|
t.isMemberExpression(node) ||
|
|
t.isOptionalMemberExpression(node)
|
|
) {
|
|
for (const selected of this.#memberValues(
|
|
node,
|
|
typed ? "typed-route-tree" : "nested-route-object",
|
|
))
|
|
admit(selected, typed);
|
|
} else if (t.isConditionalExpression(node)) {
|
|
admit(node.consequent, typed);
|
|
admit(node.alternate, typed);
|
|
} else if (t.isLogicalExpression(node)) {
|
|
admit(node.left, typed);
|
|
admit(node.right, typed);
|
|
} else if (t.isSequenceExpression(node))
|
|
for (const child of node.expressions) admit(child, typed);
|
|
else if (t.isAssignmentExpression(node)) admit(node.right, typed);
|
|
else if (t.isSpreadElement(node)) admit(node.argument, typed);
|
|
else if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
|
|
for (const child of node.arguments) admit(child, typed);
|
|
}
|
|
return inferredRoots;
|
|
}
|
|
|
|
*#walk(
|
|
root: t.Node,
|
|
site: t.Node,
|
|
typed: boolean,
|
|
index: JavaScriptBindingIndex,
|
|
options: { inferredRoot?: boolean; unknownParent?: boolean } = {},
|
|
): Generator<void> {
|
|
const mechanism = typed ? "typed-route-tree" : "nested-route-object",
|
|
frames: Frame[] = [];
|
|
// null is the known URL root; undefined retains an unknown ancestry.
|
|
// An absolute literal can re-establish a known root below either state.
|
|
this.#frame(frames, {
|
|
node: root,
|
|
parent: options.unknownParent === true ? undefined : null,
|
|
});
|
|
while (frames.length > 0 && this.#result.capacity === null) {
|
|
yield;
|
|
const frame = frames.pop()!,
|
|
node = this.#resolve(frame.node, site, index);
|
|
if (node === undefined) {
|
|
this.#claimConfiguration(frame.node, index, typed, true);
|
|
this.#unknown(
|
|
mechanism,
|
|
frame.node,
|
|
"The route configuration may be dynamic, mutated, projected or cyclic.",
|
|
);
|
|
continue;
|
|
}
|
|
let cyclic = false;
|
|
for (
|
|
let parent = frame.ancestry;
|
|
parent !== undefined;
|
|
parent = parent.previous
|
|
)
|
|
if (parent.node === node) {
|
|
cyclic = true;
|
|
break;
|
|
}
|
|
if (cyclic) {
|
|
this.#unknown(
|
|
mechanism,
|
|
frame.node,
|
|
"A cyclic route relationship cannot establish a finite whole path.",
|
|
);
|
|
continue;
|
|
}
|
|
const ancestry = { node, previous: frame.ancestry };
|
|
if (t.isArrayExpression(node)) {
|
|
for (
|
|
let at = node.elements.length - 1;
|
|
at >= 0 && this.#result.capacity === null;
|
|
at--
|
|
) {
|
|
const child = node.elements[at];
|
|
if (child !== null && child !== undefined)
|
|
this.#frame(frames, {
|
|
node: child,
|
|
parent: frame.parent,
|
|
ancestry,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (!t.isObjectExpression(node)) {
|
|
this.#claimConfiguration(frame.node, index, typed, true);
|
|
this.#unknown(
|
|
mechanism,
|
|
node,
|
|
"A route configuration is not a static object or array.",
|
|
);
|
|
continue;
|
|
}
|
|
const fields = this.#fields(node);
|
|
if (this.#result.capacity !== null) break;
|
|
const own = fields.values,
|
|
property =
|
|
own.get(typed ? "_$" : "path") ??
|
|
(typed ? undefined : own.get("route"));
|
|
const uncertain =
|
|
fields.unknownKeys ||
|
|
[...fields.dynamicNames].some(
|
|
(key) =>
|
|
typed || key === "path" || key === "route" || key === "children",
|
|
);
|
|
if (uncertain) {
|
|
this.#unknown(
|
|
mechanism,
|
|
node,
|
|
"Spread, computed keys or accessors leave route fields partially unknown.",
|
|
);
|
|
}
|
|
let link = uncertain && property === undefined ? undefined : frame.parent,
|
|
emit = property !== undefined;
|
|
if (property !== undefined) {
|
|
const literal = this.#resolve(property.value, site, index);
|
|
if (literal !== undefined) this.#propertyValues.add(literal);
|
|
const value = literal === undefined ? undefined : stringValue(literal);
|
|
if (
|
|
value === undefined ||
|
|
(value !== "" && routePath(value, { relative: true }) === undefined)
|
|
) {
|
|
this.#unknown(
|
|
mechanism,
|
|
property.value,
|
|
"The parent segment is unknown; descendant full paths are not inferred.",
|
|
);
|
|
link = undefined;
|
|
emit = false;
|
|
} else if (value.startsWith("/") || link !== undefined) {
|
|
link = {
|
|
previous: value.startsWith("/") ? null : link!,
|
|
segment: {
|
|
value,
|
|
location: range(literal!),
|
|
origin: "literal",
|
|
},
|
|
inferred: false,
|
|
};
|
|
} else emit = false;
|
|
} else if (
|
|
typed &&
|
|
frame.fallback !== undefined &&
|
|
!uncertain &&
|
|
link !== undefined
|
|
) {
|
|
link = {
|
|
previous: link,
|
|
segment: {
|
|
value: frame.fallback.key,
|
|
location: range(frame.fallback.node),
|
|
origin: "property-name",
|
|
},
|
|
inferred: true,
|
|
};
|
|
emit = true;
|
|
} else if (!typed) {
|
|
const value = own.get("index")?.value;
|
|
emit = t.isBooleanLiteral(value) && value.value;
|
|
}
|
|
if (emit && link !== undefined) {
|
|
const segments: JavaScriptRouteSegment[] = [];
|
|
let inferred = options.inferredRoot ?? false;
|
|
for (let part = link; part !== null; part = part.previous) {
|
|
segments.push(part.segment);
|
|
inferred ||= part.inferred;
|
|
}
|
|
segments.reverse();
|
|
const minimumBytes = segments.reduce(
|
|
(bytes, segment) => bytes + Buffer.byteLength(segment.value) + 1,
|
|
0,
|
|
);
|
|
if (minimumBytes > this.#remaining()) {
|
|
this.#capacity();
|
|
break;
|
|
}
|
|
let path = "";
|
|
for (const { value } of segments) {
|
|
if (value === "") continue;
|
|
path =
|
|
path === ""
|
|
? value.startsWith("/")
|
|
? value
|
|
: `/${value}`
|
|
: `${path}${path.endsWith("/") ? "" : "/"}${value}`;
|
|
}
|
|
this.#emit(
|
|
path || "/",
|
|
mechanism,
|
|
segments,
|
|
property?.value ?? node,
|
|
inferred,
|
|
);
|
|
}
|
|
if (typed) {
|
|
const entries = [...own.entries()];
|
|
for (
|
|
let at = entries.length - 1;
|
|
at >= 0 && this.#result.capacity === null;
|
|
at--
|
|
) {
|
|
const [key, child] = entries[at]!;
|
|
if (!isRouteChildKey(key, true)) continue;
|
|
this.#frame(frames, {
|
|
node: child.value,
|
|
parent: link,
|
|
ancestry,
|
|
fallback: { key, node: child.key },
|
|
});
|
|
}
|
|
} else {
|
|
const children = own.get("children");
|
|
if (children !== undefined)
|
|
this.#frame(frames, { node: children.value, parent: link, ancestry });
|
|
}
|
|
}
|
|
}
|
|
|
|
#unknown(mechanism: Mechanism, node: t.Node, reason: string): void {
|
|
const fact = { mechanism, location: range(node), reason };
|
|
if (this.#reserve("fact", fact)) this.#result.unresolved.push(fact);
|
|
}
|
|
#emit(
|
|
path: string,
|
|
mechanism: Mechanism,
|
|
segments: JavaScriptRouteSegment[],
|
|
node: t.Node,
|
|
inferred: boolean,
|
|
): void {
|
|
if (this.#result.capacity !== null) return;
|
|
if (Buffer.byteLength(path) > this.#remaining()) {
|
|
this.#capacity();
|
|
return;
|
|
}
|
|
const fact: JavaScriptRouteCandidate = {
|
|
path,
|
|
mechanism,
|
|
segments,
|
|
location: range(node),
|
|
confidence: inferred ? "low" : "medium",
|
|
derivation: inferred
|
|
? "inferred"
|
|
: segments.length === 1 && segments[0]!.value === path
|
|
? "literal"
|
|
: "joined",
|
|
};
|
|
const key = JSON.stringify([path, mechanism, node.start, node.end]);
|
|
if (this.#seen.has(key)) return;
|
|
if (this.#reserve("fact", fact)) {
|
|
this.#seen.add(key);
|
|
this.#result.candidates.push(fact);
|
|
}
|
|
}
|
|
#remaining(): number {
|
|
return this.factByteBudget - this.#factBytes - this.#workBytes;
|
|
}
|
|
#capacity(): void {
|
|
this.#result.capacity ??= {
|
|
kind: "route-work-or-facts",
|
|
limit_bytes: this.factByteBudget,
|
|
accounted_fact_bytes: this.#factBytes,
|
|
accounted_work_bytes: this.#workBytes,
|
|
};
|
|
}
|
|
#reserve(kind: "fact" | "work", value: JsonValue): boolean {
|
|
if (this.#result.capacity !== null) return false;
|
|
const bytes = jsonValueByteLength(value, this.#remaining()) + 1;
|
|
if (bytes > this.#remaining()) {
|
|
this.#capacity();
|
|
return false;
|
|
}
|
|
if (kind === "fact") this.#factBytes += bytes;
|
|
else this.#workBytes += bytes;
|
|
return true;
|
|
}
|
|
#queue(node: t.Node, value: string | null = null): boolean {
|
|
return this.#reserve("work", { location: range(node), value });
|
|
}
|
|
#frame(frames: Frame[], frame: Frame): void {
|
|
if (this.#queue(frame.node)) frames.push(frame);
|
|
}
|
|
}
|