(ctx: ResolutionContext, onYield: MaybeYield)
| 1805 | } |
| 1806 | |
| 1807 | async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> { |
| 1808 | const edges: Edge[] = []; |
| 1809 | const seen = new Set<string>(); |
| 1810 | let scanned = 0; |
| 1811 | for (const file of ctx.getAllFiles()) { |
| 1812 | if ((++scanned & 255) === 0) await onYield(); // #1091: yield mid-scan on huge graphs |
| 1813 | if (!REGISTRY_JS_EXT.test(file)) continue; |
| 1814 | const content = ctx.readFile(file); |
| 1815 | // Cheap pre-filter: a computed member access BY NAME (`ident[ident`) — the dispatch shape. |
| 1816 | if (!content || !/[\w$]\s*\[\s*[A-Za-z_$]/.test(content)) continue; |
| 1817 | // Skip minified/generated bundles (draco, three.min, base64…): their pervasive `h[x](...)` |
| 1818 | // calls + single-letter `{a:b}` literals are a false-positive minefield. Average line |
| 1819 | // length is the reliable tell — real source ~30–80, minified in the hundreds/thousands. |
| 1820 | const newlines = (content.match(/\n/g)?.length ?? 0) + 1; |
| 1821 | if (content.length / newlines > 200) continue; |
| 1822 | const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript'); |
| 1823 | |
| 1824 | // 1. Dispatch sites: `(new )?<ref>[<ident-key>]` followed by a call or a chained method. |
| 1825 | // A quoted-string key (`['save']`) does NOT match — that's a static access, not dispatch. |
| 1826 | REGISTRY_DISPATCH_RE.lastIndex = 0; |
| 1827 | const dispatches: Array<{ ref: string; line: number; chained: string | null }> = []; |
| 1828 | let dm: RegExpExecArray | null; |
| 1829 | while ((dm = REGISTRY_DISPATCH_RE.exec(safe))) { |
| 1830 | const win = safe.slice(dm.index, dm.index + 160); |
| 1831 | const cm = /\]\s*\([^)]*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win) || /\]\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win); |
| 1832 | dispatches.push({ ref: dm[1]!, line: safe.slice(0, dm.index).split('\n').length, chained: cm ? cm[1]! : null }); |
| 1833 | } |
| 1834 | if (!dispatches.length) continue; |
| 1835 | // Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`) |
| 1836 | // matches a `this.commands[k]` dispatch, not just the constructor form `this.commands = {…}`. |
| 1837 | const norm = (r: string) => r.replace(/^this\./, ''); |
| 1838 | const refs = new Set(dispatches.map((d) => norm(d.ref))); |
| 1839 | |
| 1840 | // 2. Registries: an object literal assigned to a dispatched ref, ≥2 entries resolving to callables. |
| 1841 | REGISTRY_ASSIGN_RE.lastIndex = 0; |
| 1842 | const registries = new Map<string, { names: string[]; line: number }>(); |
| 1843 | let am: RegExpExecArray | null; |
| 1844 | while ((am = REGISTRY_ASSIGN_RE.exec(safe))) { |
| 1845 | const lhs = norm(am[1] ?? am[2]!); |
| 1846 | if (!refs.has(lhs) || registries.has(lhs)) continue; |
| 1847 | const body = braceBody(safe, am.index + am[0].length - 1); |
| 1848 | if (!body) continue; |
| 1849 | const names = registryEntryNames(body); // depth-0 `key: Identifier` entries only |
| 1850 | if (names.length >= REGISTRY_MIN_ENTRIES) { |
| 1851 | registries.set(lhs, { names, line: safe.slice(0, am.index).split('\n').length }); |
| 1852 | } |
| 1853 | } |
| 1854 | if (!registries.size) continue; |
| 1855 | |
| 1856 | // 3. Link each dispatcher → each registered handler's callable entry. |
| 1857 | const nodesInFile = ctx.getNodesInFile(file); |
| 1858 | for (const d of dispatches) { |
| 1859 | const reg = registries.get(norm(d.ref)); |
| 1860 | if (!reg) continue; |
| 1861 | const disp = enclosingFn(nodesInFile, d.line); |
| 1862 | if (!disp) continue; |
| 1863 | let added = 0; |
| 1864 | for (const name of reg.names) { |
no test coverage detected