| 234 | * rather than proposing something wrong. PURE. |
| 235 | */ |
| 236 | export function proposeParameterizedHelper(members: FunctionUnit[], helperName = 'extractedHelper'): ParamProposal { |
| 237 | const decline = (reason: string): ParamProposal => ({ ok: false, reason, helperName, params: [], sketch: '', calls: [] }); |
| 238 | if (members.length < 2) return decline('need at least two functions'); |
| 239 | |
| 240 | // Neutralize each function's own name, and strip comments BEFORE aligning: two bodies that |
| 241 | // differ only in comments are the same code (dogfooding caught 100%-similar pairs declining |
| 242 | // as "structurally divergent" purely because their comments tokenized differently). |
| 243 | let bodies = members.map(m => stripComments(m.body).replace(new RegExp(`\\b${m.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'), helperName)); |
| 244 | let toks = bodies.map(b => rawTokens(b)); |
| 245 | let dropped: string[] | undefined; |
| 246 | if (toks.some(t => t.length !== toks[0]!.length)) { |
| 247 | // Mixed structural variants in one cluster (e.g. a Number family + a BigInt family): keep the |
| 248 | // LARGEST same-token-count subset and report the rest as dropped, instead of declining outright. |
| 249 | const byCount = new Map<number, number[]>(); |
| 250 | toks.forEach((t, i) => { const k = t.length; if (!byCount.has(k)) byCount.set(k, []); byCount.get(k)!.push(i); }); |
| 251 | const best = [...byCount.values()].sort((a, b) => b.length - a.length)[0]!; |
| 252 | if (best.length < 2) return decline('bodies do not align structurally (token counts differ) — too divergent to parameterize mechanically'); |
| 253 | dropped = members.filter((_, i) => !best.includes(i)).map(m => m.name); |
| 254 | members = best.map(i => members[i]!); |
| 255 | bodies = best.map(i => bodies[i]!); |
| 256 | toks = best.map(i => toks[i]!); |
| 257 | } |
| 258 | |
| 259 | // Positions where members disagree, grouped by their value-vector (co-varying positions = one param). |
| 260 | const n = toks[0]!.length; |
| 261 | const groups = new Map<string, number[]>(); |
| 262 | for (let p = 0; p < n; p++) { |
| 263 | const values = toks.map(t => t[p]!.text); |
| 264 | if (values.every(v => v === values[0])) continue; |
| 265 | const key = JSON.stringify(values); |
| 266 | if (!groups.has(key)) groups.set(key, []); |
| 267 | groups.get(key)!.push(p); |
| 268 | } |
| 269 | if (groups.size === 0) return decline('bodies are identical — use consolidate-dupes, no parameter needed'); |
| 270 | if (groups.size > 4) return decline(`${groups.size} independently-varying parts — too many to parameterize cleanly`); |
| 271 | |
| 272 | // Name each parameter from context when possible (`kind: "min"` → param `kind`), else p1/p2…. |
| 273 | const used = new Set<string>(); |
| 274 | const params: { name: string; values: string[]; positions: number[] }[] = []; |
| 275 | for (const [key, positions] of groups) { |
| 276 | const first = positions[0]!; |
| 277 | const ref = toks[0]!; |
| 278 | let name = ref[first - 1]?.text === ':' && /^[A-Za-z_$][\w$]*$/.test(ref[first - 2]?.text ?? '') ? ref[first - 2]!.text : `p${params.length + 1}`; |
| 279 | while (used.has(name)) name = `${name}_`; |
| 280 | used.add(name); |
| 281 | params.push({ name, values: JSON.parse(key), positions }); |
| 282 | } |
| 283 | |
| 284 | // Sketch: reference body with each varying token replaced by its parameter name (right-to-left). |
| 285 | let sketch = bodies[0]!; |
| 286 | const subs = params.flatMap(p => p.positions.map(pos => ({ pos, name: p.name }))) |
| 287 | .sort((a, b) => toks[0]![b.pos]!.start - toks[0]![a.pos]!.start); |
| 288 | for (const s of subs) { |
| 289 | const t = toks[0]![s.pos]!; |
| 290 | sketch = sketch.slice(0, t.start) + s.name + sketch.slice(t.start + t.text.length); |
| 291 | } |
| 292 | const calls = members.map((m, i) => ({ member: m.name, args: params.map(p => p.values[i]!) })); |
| 293 | return { ok: true, helperName, params: params.map(({ name, values }) => ({ name, values })), sketch, calls, dropped }; |