|
| 1 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 2 | +import * as fs from 'node:fs'; |
| 3 | +import * as path from 'node:path'; |
| 4 | +import * as os from 'node:os'; |
| 5 | +import { CodeGraph } from '../src'; |
| 6 | + |
| 7 | +/** |
| 8 | + * End-to-end synthesizer test for closure-collection dynamic dispatch. |
| 9 | + * |
| 10 | + * A method appends a closure to a collection property; another method iterates |
| 11 | + * that property *invoking each element* (`coll.forEach { $0() }`) — a dynamic |
| 12 | + * dispatch tree-sitter can't resolve, so a flow into the dispatcher dead-ends |
| 13 | + * before the registered closures. This is Alamofire's request-validation shape: |
| 14 | + * `DataRequest.validate` does `validators.write { $0.append(validator) }`, the |
| 15 | + * base `Request.didCompleteTask` runs `validators.forEach { $0() }`. |
| 16 | + * |
| 17 | + * Verify the synthesizer (1) links the dispatcher → each same-named registrar |
| 18 | + * across files/classes, (2) handles both the Swift `prop.write { $0.append }` |
| 19 | + * and the direct `prop.append(...)` registrar forms, (3) surfaces the wiring |
| 20 | + * site, and (4) does NOT fire on a `.forEach` that doesn't invoke its element |
| 21 | + * (the closure-invoke is the precision gate — a plain collection is skipped). |
| 22 | + */ |
| 23 | +describe('closure-collection synthesizer', () => { |
| 24 | + let dir: string; |
| 25 | + |
| 26 | + beforeEach(() => { |
| 27 | + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'closure-coll-fixture-')); |
| 28 | + }); |
| 29 | + |
| 30 | + afterEach(() => { |
| 31 | + fs.rmSync(dir, { recursive: true, force: true }); |
| 32 | + }); |
| 33 | + |
| 34 | + it('links dispatcher → registrars across files, both append forms, and skips non-invoked collections', async () => { |
| 35 | + // Base class: the dispatchers (iterate-and-invoke) + a non-closure control. |
| 36 | + fs.writeFileSync( |
| 37 | + path.join(dir, 'Request.swift'), |
| 38 | + `class Request { |
| 39 | + var validators: [() -> Void] = [] |
| 40 | + var handlers: [() -> Void] = [] |
| 41 | + var names: [String] = [] |
| 42 | +
|
| 43 | + func didCompleteTask() { |
| 44 | + let validators = validators |
| 45 | + validators.forEach { $0() } |
| 46 | + } |
| 47 | +
|
| 48 | + func runHandlers() { |
| 49 | + handlers.forEach { $0() } |
| 50 | + } |
| 51 | +
|
| 52 | + func printNames() { |
| 53 | + names.forEach { print($0) } |
| 54 | + } |
| 55 | +} |
| 56 | +` |
| 57 | + ); |
| 58 | + |
| 59 | + // Subclass: the registrars (append a closure) in a DIFFERENT file/class. |
| 60 | + fs.writeFileSync( |
| 61 | + path.join(dir, 'DataRequest.swift'), |
| 62 | + `class DataRequest: Request { |
| 63 | + func validate(_ validation: @escaping () -> Void) -> Self { |
| 64 | + let validator: () -> Void = { validation() } |
| 65 | + validators.write { $0.append(validator) } |
| 66 | + return self |
| 67 | + } |
| 68 | +
|
| 69 | + func onEvent(_ handler: @escaping () -> Void) { |
| 70 | + handlers.append(handler) |
| 71 | + } |
| 72 | +
|
| 73 | + func addName(_ n: String) { |
| 74 | + names.append(n) |
| 75 | + } |
| 76 | +} |
| 77 | +` |
| 78 | + ); |
| 79 | + |
| 80 | + const cg = await CodeGraph.init(dir, { silent: true }); |
| 81 | + await cg.indexAll(); |
| 82 | + |
| 83 | + const db = (cg as any).db.db; |
| 84 | + const rows = db |
| 85 | + .prepare( |
| 86 | + `SELECT s.name source_name, s.kind source_kind, t.name target_name, |
| 87 | + json_extract(e.metadata,'$.field') field, |
| 88 | + json_extract(e.metadata,'$.registeredAt') registeredAt |
| 89 | + FROM edges e |
| 90 | + JOIN nodes s ON s.id = e.source |
| 91 | + JOIN nodes t ON t.id = e.target |
| 92 | + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'closure-collection'` |
| 93 | + ) |
| 94 | + .all(); |
| 95 | + cg.close?.(); |
| 96 | + |
| 97 | + expect(rows.length).toBeGreaterThan(0); |
| 98 | + |
| 99 | + // Every edge originates from a dispatcher method and is a real `calls` hop. |
| 100 | + expect(rows.every((r: any) => r.source_kind === 'method')).toBe(true); |
| 101 | + |
| 102 | + // The validators flow: didCompleteTask → validate, captured via the Swift |
| 103 | + // Protected `prop.write { $0.append }` form, wiring site surfaced. |
| 104 | + const validatorsEdge = rows.find( |
| 105 | + (r: any) => r.field === 'validators' && r.target_name === 'validate' |
| 106 | + ); |
| 107 | + expect(validatorsEdge).toBeTruthy(); |
| 108 | + expect(validatorsEdge.source_name).toBe('didCompleteTask'); |
| 109 | + expect(validatorsEdge.registeredAt).toMatch(/DataRequest\.swift:\d+/); |
| 110 | + |
| 111 | + // The handlers flow: runHandlers → onEvent, via the direct `prop.append` |
| 112 | + // form — proves both registrar shapes are covered. |
| 113 | + const handlersEdge = rows.find( |
| 114 | + (r: any) => r.field === 'handlers' && r.target_name === 'onEvent' |
| 115 | + ); |
| 116 | + expect(handlersEdge).toBeTruthy(); |
| 117 | + expect(handlersEdge.source_name).toBe('runHandlers'); |
| 118 | + |
| 119 | + // Precision gate: `names.forEach { print($0) }` does NOT invoke its element, |
| 120 | + // so `names` is not a closure collection — no edge, and addName is never a target. |
| 121 | + expect(rows.some((r: any) => r.field === 'names')).toBe(false); |
| 122 | + expect(rows.some((r: any) => r.target_name === 'addName')).toBe(false); |
| 123 | + }); |
| 124 | +}); |
0 commit comments