@fiodos/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1789 @@
1
+ /**
2
+ * wireHandlers — consent-based, security-aware auto-wiring of action handlers.
3
+ *
4
+ * Runs at the very end of a WEB analysis, AFTER the orb wiring (wireWeb) and its
5
+ * confirmation. The orb mounts, but the agent cannot DO anything until each
6
+ * action detected in the manifest is connected to the real function in the app
7
+ * that performs it. This module prepares that "bridge" and asks for a SECOND,
8
+ * separate, informed confirmation before writing any code.
9
+ *
10
+ * Contract:
11
+ * 1. PREPARE — for every action in the (just produced) manifest, figure out
12
+ * whether we can connect it to a real app function WITH CONFIDENCE:
13
+ * · exported standalone function whose params we can map by name, or
14
+ * · a zustand store method (STORE.getState().method) we can map by name.
15
+ * Anything else (class methods, React context/hooks, local component
16
+ * functions, ambiguous params, unknown signatures) is NEVER guessed: it is
17
+ * marked "manual" with a reason. A wrong wiring is worse than none.
18
+ * 2. DOCUMENT — write a human-readable doc into the project's Fiodos folder
19
+ * explaining exactly what will be wired, which files are created, why it is
20
+ * needed, and the security notes (which actions stay behind confirmation).
21
+ * 3. CONFIRM — print the doc path and ask "[yes/no]" in the terminal. On a
22
+ * non-TTY (CI) or "no", nothing in the developer's code is written.
23
+ * 4. APPLY (yes only) — write the generated registry file idempotently (it is
24
+ * fully regenerated each run and clearly marked GENERATED). The developer's
25
+ * existing files are never edited; the one-line `registries=` wiring is
26
+ * printed so the mount step matches how the orb itself is wired.
27
+ *
28
+ * SECURITY (non-negotiable):
29
+ * · A generated handler only CALLS your function. It NEVER decides whether a
30
+ * sensitive action runs: the Fiodos engine enforces the manifest's
31
+ * requireConfirmation / voiceConfirmation BEFORE any handler is invoked
32
+ * (see @fiodos/web-core decideAction). So auto-wiring cannot bypass a
33
+ * confirmation, by construction.
34
+ * · No secrets are read, embedded or exposed; only function names + the
35
+ * params the manifest already declares are referenced.
36
+ * · Low confidence → "manual", with the reason in the doc. Never guess.
37
+ */
38
+ 'use strict';
39
+
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const readline = require('readline');
43
+ const { probeEffect, compileRegressed } = require('./verifyWire');
44
+
45
+ const DOC_BASENAME = 'FYODOS_HANDLERS.md';
46
+ const REGISTRY_BASENAME = 'handlers.generated';
47
+ const BRIDGE_BASENAME = 'bridge';
48
+ const REGISTRY_EXPORT = 'fyodosGeneratedRegistries';
49
+ const GENERATED_MARKER = 'GENERATED by Fiodos — handler wiring';
50
+ // Markers wrapping every line Fiodos inserts INTO the user's own files, so the
51
+ // edits are idempotent (re-runs strip+rewrite) and trivially reversible.
52
+ const EDIT_START = '// FYODOS:BRIDGE:START (auto-generado por Fiodos — seguro de eliminar)';
53
+ const EDIT_END = '// FYODOS:BRIDGE:END';
54
+
55
+ function esc(name) {
56
+ return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+
59
+ function normRel(p) {
60
+ return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '');
61
+ }
62
+
63
+ function readFileSafe(appRoot, fileRel) {
64
+ const abs = path.join(appRoot, normRel(fileRel));
65
+ if (!fs.existsSync(abs)) return null;
66
+ try {
67
+ return fs.readFileSync(abs, 'utf8');
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** Count non-overlapping occurrences of a literal substring. */
74
+ function countOccurrences(haystack, needle) {
75
+ if (!needle) return 0;
76
+ let count = 0;
77
+ let idx = haystack.indexOf(needle);
78
+ while (idx !== -1) {
79
+ count += 1;
80
+ idx = haystack.indexOf(needle, idx + needle.length);
81
+ }
82
+ return count;
83
+ }
84
+
85
+ // ── Mechanical verification (the safety net for AI-proposed wiring) ─────────────
86
+
87
+ /** True if `symbol` appears as a standalone identifier somewhere in the file. */
88
+ function fileHasSymbol(appRoot, fileRel, symbol) {
89
+ if (!fileRel || !symbol) return false;
90
+ const abs = path.join(appRoot, normRel(fileRel));
91
+ if (!fs.existsSync(abs)) return false;
92
+ let content;
93
+ try {
94
+ content = fs.readFileSync(abs, 'utf8');
95
+ } catch {
96
+ return false;
97
+ }
98
+ return new RegExp(`(^|[^\\w$])${esc(symbol)}([^\\w$]|$)`).test(content);
99
+ }
100
+
101
+ /**
102
+ * Identifiers a generated `call` expression is allowed to reference for free
103
+ * (i.e. without an explicit import). Anything else must be an imported name, or
104
+ * the wiring is rejected — we never write code that calls an unknown symbol.
105
+ */
106
+ // JS syntax keywords are not symbols; they must never count as "unknown ids".
107
+ // Includes TS-only keywords/built-in type names so a cast like `x as any` or a
108
+ // type annotation never looks like a call to a hallucinated identifier.
109
+ const JS_KEYWORDS = new Set([
110
+ 'const', 'let', 'var', 'if', 'else', 'for', 'while', 'do', 'switch', 'case',
111
+ 'break', 'continue', 'return', 'function', 'class', 'extends', 'new', 'typeof',
112
+ 'instanceof', 'in', 'of', 'void', 'delete', 'await', 'async', 'yield', 'throw',
113
+ 'try', 'catch', 'finally', 'this', 'super', 'default', 'export', 'import',
114
+ 'from', 'as', 'true', 'false', 'null', 'undefined',
115
+ // TypeScript type keywords / built-in type names (appear in casts/annotations).
116
+ 'any', 'unknown', 'never', 'string', 'number', 'boolean', 'object', 'symbol',
117
+ 'bigint', 'readonly', 'keyof', 'infer', 'satisfies', 'is',
118
+ ]);
119
+
120
+ const SAFE_GLOBALS = new Set([
121
+ 'params', 'await', 'async', 'new', 'typeof', 'void', 'delete', 'in', 'of',
122
+ 'instanceof', 'return', 'true', 'false', 'null', 'undefined', 'this',
123
+ 'crypto', 'Date', 'JSON', 'Math', 'Number', 'String', 'Boolean', 'Object',
124
+ 'Array', 'Promise', 'console', 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
125
+ 'RegExp', 'Map', 'Set', 'Symbol', 'BigInt', 'structuredClone', 'globalThis',
126
+ ]);
127
+
128
+ function stripLiterals(src) {
129
+ return String(src || '')
130
+ .replace(/`(?:\\.|[^`\\])*`/g, '``')
131
+ .replace(/'(?:\\.|[^'\\])*'/g, "''")
132
+ .replace(/"(?:\\.|[^"\\])*"/g, '""');
133
+ }
134
+
135
+ /**
136
+ * Names BOUND locally inside the expression (so they are NOT external symbols
137
+ * that must be imported): const/let/var declarations (incl. destructuring),
138
+ * function/arrow parameters and catch bindings. Heuristic, used only to avoid
139
+ * false rejections in the safety check.
140
+ */
141
+ function declaredLocals(expr) {
142
+ const out = new Set();
143
+ const src = stripLiterals(expr);
144
+ const addNames = (binding) => {
145
+ const ids = String(binding || '').match(/[A-Za-z_$][\w$]*/g) || [];
146
+ for (const id of ids) out.add(id);
147
+ };
148
+ // const/let/var <binding> =
149
+ let m;
150
+ const declRe = /\b(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[A-Za-z_$][\w$]*)/g;
151
+ while ((m = declRe.exec(src))) addNames(m[1]);
152
+ // arrow params: (a, b) => and bareName =>
153
+ const arrowParenRe = /\(([^()]*)\)\s*=>/g;
154
+ while ((m = arrowParenRe.exec(src))) addNames(m[1]);
155
+ const arrowBareRe = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*=>/g;
156
+ while ((m = arrowBareRe.exec(src))) out.add(m[1]);
157
+ // function (a, b) { and catch (e) {
158
+ const fnRe = /\bfunction\b[^(]*\(([^)]*)\)/g;
159
+ while ((m = fnRe.exec(src))) addNames(m[1]);
160
+ const catchRe = /\bcatch\s*\(([^)]*)\)/g;
161
+ while ((m = catchRe.exec(src))) addNames(m[1]);
162
+ return out;
163
+ }
164
+
165
+ /**
166
+ * Collect the "head" identifiers a JS expression reads from the outer scope:
167
+ * skips property accesses (after `.`), object-literal keys (before `:`) and
168
+ * string/template literal contents. Heuristic, but it only feeds a SAFETY check
169
+ * — anything we cannot account for makes the wiring fail closed (→ review).
170
+ */
171
+ function freeIdentifiers(expr) {
172
+ const out = new Set();
173
+ const stripped = stripLiterals(expr);
174
+ const re = /(\.)?\b([A-Za-z_$][\w$]*)\b(\s*:)?/g;
175
+ let m;
176
+ while ((m = re.exec(stripped))) {
177
+ const isMember = Boolean(m[1]);
178
+ const isObjectKey = Boolean(m[3]);
179
+ if (isMember || isObjectKey) continue;
180
+ out.add(m[2]);
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * Detect the "detached instance" anti-pattern: the call constructs an object
187
+ * with `new X()`, binds it to a local, then uses that local as the receiver of
188
+ * a method call (e.g. `const svc = new FooService(); svc.mutate()`). For DI
189
+ * singletons (Angular @Injectable services, etc.) this creates a NEW instance
190
+ * disconnected from the one the UI renders, so the action would never be
191
+ * observable. We reject it rather than wire something that silently does
192
+ * nothing. `new X(...)` used purely as a value/argument is left alone.
193
+ */
194
+ function detachedInstanceReceiver(expr) {
195
+ const src = stripLiterals(expr);
196
+ const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+([A-Za-z_$][\w$]*)/g;
197
+ let m;
198
+ while ((m = re.exec(src))) {
199
+ const local = m[1];
200
+ if (new RegExp(`\\b${esc(local)}\\s*\\.`).test(src)) return m[2];
201
+ }
202
+ return null;
203
+ }
204
+
205
+ // ── Signature parsing ─────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Split a raw parameter list into structured params, honouring nesting and
209
+ * default values. A destructured/rest param has name=null and safe=false: it
210
+ * can only be left unmapped if it is optional.
211
+ */
212
+ function parseParamList(raw) {
213
+ const s = String(raw || '').trim();
214
+ if (!s) return [];
215
+ const parts = [];
216
+ let depth = 0;
217
+ let cur = '';
218
+ for (const ch of s) {
219
+ if ('([{'.includes(ch)) depth += 1;
220
+ else if (')]}'.includes(ch)) depth -= 1;
221
+ if (ch === ',' && depth === 0) {
222
+ parts.push(cur);
223
+ cur = '';
224
+ } else {
225
+ cur += ch;
226
+ }
227
+ }
228
+ if (cur.trim()) parts.push(cur);
229
+ return parts.map((p) => {
230
+ const t = p.trim();
231
+ const optional = /[?=]/.test(t) || t.startsWith('...');
232
+ if (t.startsWith('{') || t.startsWith('[') || t.startsWith('...')) {
233
+ return { name: null, optional, raw: t, safe: false };
234
+ }
235
+ const idm = t.match(/^([A-Za-z_$][\w$]*)/);
236
+ return { name: idm ? idm[1] : null, optional, raw: t, safe: Boolean(idm) };
237
+ });
238
+ }
239
+
240
+ /** Exported standalone function/const. Returns { params, isDefault } or null. */
241
+ function findExportedFunction(content, name) {
242
+ const n = esc(name);
243
+ const def = content.match(new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s+${n}\\s*\\(([^)]*)\\)`));
244
+ if (def) return { params: parseParamList(def[1]), isDefault: true };
245
+
246
+ const patterns = [
247
+ new RegExp(`export\\s+(?:async\\s+)?function\\s+${n}\\s*\\(([^)]*)\\)`),
248
+ new RegExp(`export\\s+const\\s+${n}\\s*=\\s*(?:async\\s*)?\\(([^)]*)\\)\\s*=>`),
249
+ new RegExp(`export\\s+const\\s+${n}\\s*=\\s*(?:async\\s+)?function\\s*[A-Za-z0-9_$]*\\s*\\(([^)]*)\\)`),
250
+ ];
251
+ for (const re of patterns) {
252
+ const m = content.match(re);
253
+ if (m) return { params: parseParamList(m[1]), isDefault: false };
254
+ }
255
+
256
+ // Re-export of a local definition: `export { name }` + a local def.
257
+ if (new RegExp(`export\\s*\\{[^}]*\\b${n}\\b[^}]*\\}`).test(content)) {
258
+ const local =
259
+ content.match(new RegExp(`(?:async\\s+)?function\\s+${n}\\s*\\(([^)]*)\\)`)) ||
260
+ content.match(new RegExp(`const\\s+${n}\\s*=\\s*(?:async\\s*)?\\(([^)]*)\\)\\s*=>`));
261
+ if (local) return { params: parseParamList(local[1]), isDefault: false };
262
+ }
263
+
264
+ // CommonJS: `exports.name = (...) =>` / `module.exports.name = function(...)`.
265
+ const cjsAssign = content.match(
266
+ new RegExp(`(?:module\\.)?exports\\.${n}\\s*=\\s*(?:async\\s*)?(?:function\\s*[A-Za-z0-9_$]*\\s*)?\\(([^)]*)\\)`),
267
+ );
268
+ if (cjsAssign) return { params: parseParamList(cjsAssign[1]), isDefault: false };
269
+
270
+ // CommonJS: `module.exports = { name, … }` + a local definition.
271
+ if (new RegExp(`module\\.exports\\s*=\\s*\\{[^}]*\\b${n}\\b`).test(content)) {
272
+ const local =
273
+ content.match(new RegExp(`(?:async\\s+)?function\\s+${n}\\s*\\(([^)]*)\\)`)) ||
274
+ content.match(new RegExp(`const\\s+${n}\\s*=\\s*(?:async\\s*)?\\(([^)]*)\\)\\s*=>`));
275
+ if (local) return { params: parseParamList(local[1]), isDefault: false };
276
+ }
277
+ return null;
278
+ }
279
+
280
+ /** Zustand-style store method. Returns { store, params } or null. */
281
+ function findStoreMethod(content, name) {
282
+ const storeM = content.match(
283
+ /export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*create(?:WithEqualityFn|Store)?\s*[<(]/,
284
+ );
285
+ if (!storeM) return null;
286
+ const store = storeM[1];
287
+ const createIdx = storeM.index || 0;
288
+ // Search only WITHIN the store body. A TypeScript interface declaring the same
289
+ // method (e.g. `addToCart: (...) => boolean;`) appears BEFORE create(...) and
290
+ // would otherwise be matched instead of the real implementation.
291
+ const body = content.slice(createIdx);
292
+ const n = esc(name);
293
+ const patterns = [
294
+ new RegExp(`${n}\\s*:\\s*(?:async\\s*)?\\(([^)]*)\\)\\s*=>`),
295
+ new RegExp(`${n}\\s*:\\s*(?:async\\s+)?function\\s*\\(([^)]*)\\)`),
296
+ new RegExp(`${n}\\s*\\(([^)]*)\\)\\s*\\{`),
297
+ ];
298
+ for (const re of patterns) {
299
+ const m = body.match(re);
300
+ if (m) return { store, params: parseParamList(m[1]) };
301
+ }
302
+ return null;
303
+ }
304
+
305
+ /**
306
+ * Map the target function's parameters to the manifest-declared params, by
307
+ * NAME (never by position — positional guessing is exactly what we must avoid).
308
+ * Returns { ok:true, args } or { ok:false, reason }.
309
+ */
310
+ function mapParams(targetParams, manifestParams) {
311
+ const manifestNames = new Set(manifestParams.map((p) => p.name));
312
+ const targetNames = new Set(targetParams.filter((p) => p.name).map((p) => p.name));
313
+
314
+ for (const mp of manifestParams) {
315
+ if (mp.required && !targetNames.has(mp.name)) {
316
+ return {
317
+ ok: false,
318
+ reason: `el parámetro requerido '${mp.name}' del manifiesto no existe en la firma de la función`,
319
+ };
320
+ }
321
+ }
322
+
323
+ const args = [];
324
+ for (const tp of targetParams) {
325
+ if (!tp.safe || tp.name === null) {
326
+ if (tp.optional) {
327
+ args.push(null);
328
+ continue;
329
+ }
330
+ return {
331
+ ok: false,
332
+ reason: 'la función usa desestructuración o un parámetro rest que no se puede mapear con seguridad',
333
+ };
334
+ }
335
+ if (manifestNames.has(tp.name)) {
336
+ args.push(`params[${JSON.stringify(tp.name)}]`);
337
+ } else if (tp.optional) {
338
+ args.push(null);
339
+ } else {
340
+ return {
341
+ ok: false,
342
+ reason: `el parámetro '${tp.name}' de la función no tiene un equivalente en el manifiesto`,
343
+ };
344
+ }
345
+ }
346
+ while (args.length && args[args.length - 1] === null) args.pop();
347
+ return { ok: true, args: args.map((a) => (a === null ? 'undefined' : a)) };
348
+ }
349
+
350
+ // ── Plan ──────────────────────────────────────────────────────────────────────
351
+
352
+ function relImport(fromDirRel, toFileRel) {
353
+ let rel = path.relative(fromDirRel, normRel(toFileRel)).split(path.sep).join('/');
354
+ rel = rel.replace(/\.(t|j)sx?$/, '');
355
+ if (!rel.startsWith('.')) rel = `./${rel}`;
356
+ return rel;
357
+ }
358
+
359
+ /**
360
+ * Turn the AI's proposed wiring for ONE action into a verified `auto` entry, or
361
+ * return { reason } when the proposal cannot be safely trusted. This is the
362
+ * heart of the new model: the AI proposes (imports + a call expression read
363
+ * straight from the user's code) and the verifier confirms every symbol exists
364
+ * and every identifier the call reads is either imported or a safe global.
365
+ */
366
+ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
367
+ if (!aiW || typeof aiW !== 'object') return { reason: 'la IA no propuso cableado para esta acción' };
368
+ if (aiW.strategy === 'bridge') {
369
+ return buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW });
370
+ }
371
+ if (aiW.strategy && aiW.strategy !== 'module') {
372
+ return { reason: `estrategia de cableado desconocida: '${aiW.strategy}'` };
373
+ }
374
+
375
+ const call = String(aiW.call || '').trim();
376
+ if (!call) return { reason: 'la IA no aportó una expresión de llamada (call) verificable' };
377
+
378
+ const rawImports = Array.isArray(aiW.imports) ? aiW.imports : [];
379
+ // 1) Every required symbol must really exist where the AI says it does.
380
+ const required = Array.isArray(aiW.requiredSymbols) ? aiW.requiredSymbols : [];
381
+ for (const r of required) {
382
+ if (!fileHasSymbol(appRoot, r && r.file, r && r.name)) {
383
+ return {
384
+ reason: `el verificador no encontró el símbolo '${r && r.name}' en '${normRel(r && r.file)}' (la IA lo propuso pero no existe en el código)`,
385
+ };
386
+ }
387
+ }
388
+
389
+ // 2) Build verified imports. Each imported file+name must exist on disk.
390
+ const imports = [];
391
+ const importedNames = new Set();
392
+ for (const imp of rawImports) {
393
+ const name = String((imp && imp.name) || '').trim();
394
+ const from = normRel(imp && imp.from);
395
+ const kind = (imp && imp.kind) || 'named';
396
+ if (!name || !from) return { reason: 'un import propuesto por la IA está incompleto (falta name/from)' };
397
+ if (!fs.existsSync(path.join(appRoot, from))) {
398
+ return { reason: `el import propuesto apunta a un archivo inexistente: '${from}'` };
399
+ }
400
+ // For named imports the symbol must be present in that file; default/namespace
401
+ // bind the module's default/whole export so only file existence is checked.
402
+ if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) {
403
+ return { reason: `el import nombrado '${name}' no existe en '${from}'` };
404
+ }
405
+ imports.push({ kind, name, importPath: relImport(fyodosDirRel, from) });
406
+ importedNames.add(name);
407
+ }
408
+
409
+ // 3) Reject the "detached instance" anti-pattern (new Service() + mutate).
410
+ const detached = detachedInstanceReceiver(call);
411
+ if (detached) {
412
+ return {
413
+ reason:
414
+ `la expresión instancia '${detached}' con 'new' y la usa como receptor; ` +
415
+ 'si es un servicio/estado inyectado (DI) o un singleton, esa instancia nueva ' +
416
+ 'queda desconectada de la que ve la UI y la acción no tendría efecto observable. ' +
417
+ 'Necesita un puente al singleton real en vez de instanciarlo.',
418
+ };
419
+ }
420
+
421
+ // 4) Safety: every free identifier the call reads must be imported, a locally
422
+ // declared binding, or a safe global. Anything else fails closed (→ review).
423
+ const locals = declaredLocals(call);
424
+ for (const id of freeIdentifiers(call)) {
425
+ if (JS_KEYWORDS.has(id) || importedNames.has(id) || SAFE_GLOBALS.has(id) || locals.has(id)) continue;
426
+ return {
427
+ reason: `la expresión de la IA referencia '${id}', que no se importa ni es un global/variable local conocido; no se cablea para no llamar a algo inexistente`,
428
+ };
429
+ }
430
+
431
+ return {
432
+ ...base,
433
+ confidence: 'auto',
434
+ source: 'ai',
435
+ kind: 'ai-call',
436
+ imports,
437
+ callExpr: call,
438
+ usesParams: /\bparams\b/.test(call),
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Verify the AI's BRIDGE proposal for ONE action. A bridge edits the user's own
444
+ * component (under consent) to register the live, in-scope function/instance so
445
+ * the standalone registry can reach component-local state. The verifier confirms
446
+ * the edit can be applied SAFELY and that nothing is fabricated:
447
+ * · the component file exists and the anchor line is present EXACTLY once
448
+ * (so the insertion point is unambiguous and idempotent),
449
+ * · every requiredSymbol exists in the code,
450
+ * · `invoke` does not create a detached `new Service()` instance,
451
+ * · every identifier `invoke` reads is `args`/`this`, a safe global, an import
452
+ * the AI declared (and that resolves), or a symbol that exists in the file.
453
+ * Returns an `auto` entry (kind 'bridge') or { reason } → review.
454
+ */
455
+ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
456
+ const b = aiW.bridge || {};
457
+ const file = normRel(b.file);
458
+ const anchor = String(b.anchor || '').trim();
459
+ const invoke = String(b.invoke || '').trim();
460
+ const why = aiW.bridgeReason ? ` (${aiW.bridgeReason})` : '';
461
+
462
+ if (!file) return { reason: `la IA marcó puente pero no indicó el archivo del componente${why}` };
463
+ const content = readFileSafe(appRoot, file);
464
+ if (content == null) return { reason: `no se pudo leer el componente '${file}' para aplicar el puente` };
465
+ if (!invoke) return { reason: `la IA no aportó la expresión 'invoke' del puente para '${file}'` };
466
+ if (!anchor) return { reason: `la IA no aportó un ancla de inserción en '${file}'` };
467
+
468
+ const occ = countOccurrences(content, anchor);
469
+ if (occ === 0) return { reason: `el ancla de inserción no aparece en '${file}'; no se puede ubicar el puente con seguridad` };
470
+ if (occ > 1) return { reason: `el ancla de inserción aparece ${occ} veces en '${file}'; es ambigua, no se inserta a ciegas` };
471
+
472
+ // requiredSymbols must really exist.
473
+ const required = Array.isArray(aiW.requiredSymbols) ? aiW.requiredSymbols : [];
474
+ for (const r of required) {
475
+ if (!fileHasSymbol(appRoot, r && r.file, r && r.name)) {
476
+ return { reason: `el verificador no encontró el símbolo '${r && r.name}' en '${normRel(r && r.file)}' (puente no verificable)` };
477
+ }
478
+ }
479
+
480
+ // Never create a fresh, detached instance of a service/singleton.
481
+ const detached = detachedInstanceReceiver(invoke);
482
+ if (detached) {
483
+ return {
484
+ reason:
485
+ `el puente instancia '${detached}' con 'new' y la usa como receptor; eso crea una ` +
486
+ 'instancia desconectada de la que ve la UI. Debe usar la instancia real (p. ej. this.<servicio>), no instanciarla.',
487
+ };
488
+ }
489
+
490
+ // Verify extra imports the invoke needs.
491
+ const imports = [];
492
+ const importedNames = new Set();
493
+ for (const imp of Array.isArray(b.imports) ? b.imports : []) {
494
+ const name = String((imp && imp.name) || '').trim();
495
+ const from = normRel(imp && imp.from);
496
+ const kind = (imp && imp.kind) || 'named';
497
+ if (!name || !from) return { reason: 'un import del puente está incompleto (falta name/from)' };
498
+ if (!fs.existsSync(path.join(appRoot, from))) return { reason: `el import del puente apunta a un archivo inexistente: '${from}'` };
499
+ if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) return { reason: `el import nombrado '${name}' no existe en '${from}'` };
500
+ imports.push({ kind, name, importPath: relImport(path.dirname(file), from) });
501
+ importedNames.add(name);
502
+ }
503
+
504
+ // Safety: identifiers `invoke` reads must be accounted for. Inside a component
505
+ // the scope is rich, so we accept anything that actually appears in the file;
506
+ // truly unknown identifiers (typos, hallucinations) fail closed.
507
+ const locals = declaredLocals(invoke);
508
+ for (const id of freeIdentifiers(invoke)) {
509
+ if (id === 'args' || JS_KEYWORDS.has(id) || SAFE_GLOBALS.has(id) || locals.has(id) || importedNames.has(id)) continue;
510
+ if (fileHasSymbol(appRoot, file, id)) continue;
511
+ return {
512
+ reason: `el puente referencia '${id}', que no está en el ámbito del componente '${file}' ni se importa; no se cablea para no romper el código`,
513
+ };
514
+ }
515
+
516
+ const bridgeMethod = base.intent;
517
+ const scope = b.scope === 'class-field' ? 'class-field' : 'statement';
518
+ return {
519
+ ...base,
520
+ confidence: 'auto',
521
+ source: 'ai',
522
+ kind: 'bridge',
523
+ bridge: {
524
+ file,
525
+ anchor,
526
+ invoke,
527
+ scope,
528
+ method: bridgeMethod,
529
+ imports,
530
+ importPathToBridge: relImport(path.dirname(file), path.join(fyodosDirRel, BRIDGE_BASENAME)),
531
+ },
532
+ callExpr: `getFiodosBridge()[${JSON.stringify(bridgeMethod)}](params)`,
533
+ usesParams: true,
534
+ };
535
+ }
536
+
537
+ /**
538
+ * Mechanical fallback for ONE action (the original pattern detector), now used
539
+ * only when the AI did not provide usable wiring. Returns an `auto` entry in the
540
+ * unified shape, or { reason }.
541
+ */
542
+ function buildMechanicalEntry({ appRoot, fyodosDirRel, base, manifestParams }) {
543
+ const { file: evFile, handler } = base;
544
+ if (!evFile) return { reason: 'el análisis no aportó un archivo de evidencia para esta acción' };
545
+ const absFile = path.join(appRoot, evFile);
546
+ let content = null;
547
+ if (fs.existsSync(absFile)) {
548
+ try {
549
+ content = fs.readFileSync(absFile, 'utf8');
550
+ } catch {
551
+ content = null;
552
+ }
553
+ }
554
+ if (!content) return { reason: `no se pudo leer el archivo de evidencia '${evFile}'` };
555
+
556
+ const exported = findExportedFunction(content, handler);
557
+ const store = exported ? null : findStoreMethod(content, handler);
558
+ if (!exported && !store) {
559
+ return {
560
+ reason:
561
+ `no se encontró '${handler}' como función exportada ni como método de store en '${evFile}'. ` +
562
+ 'Puede vivir en un componente, una clase o un hook/contexto. No se cablea a ciegas.',
563
+ };
564
+ }
565
+
566
+ const targetParams = (exported || store).params;
567
+ const mapped = mapParams(targetParams, manifestParams);
568
+ if (!mapped.ok) return { reason: `firma no mapeable con seguridad: ${mapped.reason}` };
569
+
570
+ const importPath = relImport(fyodosDirRel, evFile);
571
+ if (exported) {
572
+ const localName = exported.isDefault ? `__fyodos_${handler}` : handler;
573
+ return {
574
+ ...base,
575
+ confidence: 'auto',
576
+ source: 'mechanical',
577
+ kind: 'exported-fn',
578
+ imports: [{ kind: exported.isDefault ? 'default' : 'named', name: localName, importPath }],
579
+ callExpr: `${localName}(${mapped.args.join(', ')})`,
580
+ usesParams: /params\[/.test(mapped.args.join(', ')),
581
+ };
582
+ }
583
+ return {
584
+ ...base,
585
+ confidence: 'auto',
586
+ source: 'mechanical',
587
+ kind: 'store-method',
588
+ imports: [{ kind: 'named', name: store.store, importPath }],
589
+ callExpr: `${store.store}.getState().${handler}(${mapped.args.join(', ')})`,
590
+ usesParams: /params\[/.test(mapped.args.join(', ')),
591
+ };
592
+ }
593
+
594
+ /**
595
+ * Build the wiring plan from the produced manifest + evidence + AI wiring.
596
+ * Source of truth is the AI's per-action wiring (verified); the mechanical
597
+ * detector is only a fallback. Anything still unverified becomes a `review`
598
+ * entry (never wired blindly).
599
+ *
600
+ * @param {object} args
601
+ * @param {string} args.appRoot
602
+ * @param {object} args.manifest
603
+ * @param {object} args.evidence { actions: { intent: { file, symbol, whatItDoes } } }
604
+ * @param {object} [args.wiring] { <intent>: { strategy, imports, call, requiredSymbols } }
605
+ * @param {string} args.fyodosDirRel where the generated registry will live
606
+ */
607
+ function prepareHandlerPlan({ appRoot, manifest, evidence, wiring = {}, fyodosDirRel }) {
608
+ const actions = (manifest && manifest.actions) || [];
609
+ const evActions = (evidence && evidence.actions) || {};
610
+ const aiWiring = wiring || {};
611
+ const entries = [];
612
+
613
+ for (const action of actions) {
614
+ const intent = action.intent;
615
+ const handler = action.handler || intent;
616
+ const ev = evActions[intent] || {};
617
+ const evFile = normRel(ev.file);
618
+ const manifestParams = Object.entries(action.parameters || {}).map(([name, spec]) => ({
619
+ name,
620
+ required: spec && spec.required === true,
621
+ }));
622
+
623
+ const base = {
624
+ intent,
625
+ handler,
626
+ label: action.label || intent,
627
+ file: evFile || null,
628
+ symbol: ev.symbol || null,
629
+ whatItDoes: ev.whatItDoes || ev.what || null,
630
+ requireConfirmation: action.requireConfirmation === true,
631
+ voiceConfirmation: action.voiceConfirmation || null,
632
+ idempotencyCheck: action.idempotencyCheck || null,
633
+ manifestParams,
634
+ };
635
+
636
+ // 1) AI-proposed wiring (verified). Primary source.
637
+ const aiTry = buildAiEntry({ appRoot, fyodosDirRel, base, aiW: aiWiring[intent], manifestParams });
638
+ if (aiTry.confidence === 'auto') {
639
+ entries.push(aiTry);
640
+ continue;
641
+ }
642
+
643
+ // 2) Mechanical fallback (the old pattern detector).
644
+ const mechTry = buildMechanicalEntry({ appRoot, fyodosDirRel, base, manifestParams });
645
+ if (mechTry.confidence === 'auto') {
646
+ entries.push({ ...mechTry, aiReason: aiTry.reason });
647
+ continue;
648
+ }
649
+
650
+ // 3) Neither could be verified → needs review, with both reasons.
651
+ entries.push({
652
+ ...base,
653
+ confidence: 'review',
654
+ reason: aiTry.reason || mechTry.reason,
655
+ aiReason: aiTry.reason,
656
+ mechReason: mechTry.reason,
657
+ });
658
+ }
659
+
660
+ const auto = entries.filter((e) => e.confidence === 'auto');
661
+ // `manual` retained as an alias for backward-compatible callers/tests.
662
+ const review = entries.filter((e) => e.confidence === 'review');
663
+ return { entries, auto, review, manual: review };
664
+ }
665
+
666
+ // ── Registry codegen (idempotent: regenerated wholesale, clearly marked) ───────
667
+
668
+ function buildRegistryModule(plan, opts = {}) {
669
+ const ts = opts.ts !== false;
670
+ const esm = opts.esm !== false;
671
+ const hasBridge = plan.auto.some((e) => e.kind === 'bridge');
672
+
673
+ // Group MODULE-strategy imports by source path. Bridge entries do NOT import
674
+ // the user's symbols here — they reach them through the bridge holder — so
675
+ // their `imports` belong to the component edit, not the registry.
676
+ const importsByPath = new Map();
677
+ for (const e of plan.auto) {
678
+ if (e.kind === 'bridge') continue;
679
+ for (const imp of e.imports || []) {
680
+ if (!importsByPath.has(imp.importPath)) {
681
+ importsByPath.set(imp.importPath, { named: new Set(), defaults: new Set(), namespaces: new Set() });
682
+ }
683
+ const grp = importsByPath.get(imp.importPath);
684
+ if (imp.kind === 'default') grp.defaults.add(imp.name);
685
+ else if (imp.kind === 'namespace') grp.namespaces.add(imp.name);
686
+ else grp.named.add(imp.name);
687
+ }
688
+ }
689
+
690
+ const importLines = [];
691
+ if (hasBridge) {
692
+ importLines.push(
693
+ esm
694
+ ? `import { getFiodosBridge } from './${BRIDGE_BASENAME}';`
695
+ : `const { getFiodosBridge } = require('./${BRIDGE_BASENAME}');`,
696
+ );
697
+ }
698
+ for (const [p, grp] of importsByPath) {
699
+ if (esm) {
700
+ for (const local of grp.defaults) importLines.push(`import ${local} from '${p}';`);
701
+ for (const local of grp.namespaces) importLines.push(`import * as ${local} from '${p}';`);
702
+ if (grp.named.size) importLines.push(`import { ${[...grp.named].join(', ')} } from '${p}';`);
703
+ } else {
704
+ if (grp.named.size) importLines.push(`const { ${[...grp.named].join(', ')} } = require('${p}');`);
705
+ for (const local of grp.defaults) importLines.push(`const ${local} = require('${p}');`);
706
+ for (const local of grp.namespaces) importLines.push(`const ${local} = require('${p}');`);
707
+ }
708
+ }
709
+
710
+ // Self-contained types (no @fyodos import) so the file typechecks even before
711
+ // the SDK is installed; the shape stays structurally compatible with
712
+ // ActionRegistries when passed to <FiodosAgent registries={...}/>.
713
+ const typeDefs = ts
714
+ ? 'type FiodosActionResult = { success: boolean; data?: Record<string, unknown>; error?: string };\n' +
715
+ 'type FiodosRegistries = {\n' +
716
+ ' handlers: Record<string, (params: Record<string, any>) => Promise<FiodosActionResult>>;\n' +
717
+ ' idempotencyCheckers: Record<string, (params: Record<string, any>) => boolean | Promise<boolean>>;\n' +
718
+ '};\n'
719
+ : '';
720
+ const resultType = ts ? ': Promise<FiodosActionResult>' : '';
721
+ const paramsType = ts ? ': Record<string, any>' : '';
722
+
723
+ const handlerBlocks = plan.auto.map((e) => {
724
+ const takesParams = e.usesParams != null ? e.usesParams : /\bparams\b/.test(e.callExpr);
725
+ const arg = takesParams ? `params${paramsType}` : `_params${paramsType}`;
726
+ const confNote = e.requireConfirmation
727
+ ? '\n // Acción con confirmación: el motor de Fiodos ya pidió confirmación ANTES de llamar aquí.'
728
+ : '';
729
+ // `: unknown` lets the truthiness test below be legal even when the wired
730
+ // call returns void (e.g. db.delete(...)) — otherwise TS errors on `result &&`.
731
+ const resultDecl = ts ? 'const result: unknown' : 'const result';
732
+ return (
733
+ ` ${e.handler}: async (${arg})${resultType} => {${confNote}\n` +
734
+ ` try {\n` +
735
+ ` ${resultDecl} = await Promise.resolve(${e.callExpr});\n` +
736
+ ` return { success: true, data: (result && typeof result === 'object') ? result as Record<string, unknown> : { result } };\n` +
737
+ ` } catch (err) {\n` +
738
+ ` return { success: false, error: err instanceof Error ? err.message : String(err) };\n` +
739
+ ` }\n` +
740
+ ` },`
741
+ );
742
+ });
743
+
744
+ // Plain-JS variant: drop the `as` cast.
745
+ const handlerText = (ts ? handlerBlocks.join('\n') : handlerBlocks.join('\n').replace(/ as Record<string, unknown>/g, ''));
746
+
747
+ const manualNotes = plan.manual.map(
748
+ (e) => ` // MANUAL — '${e.intent}' (${e.handler}): ${e.reason}`,
749
+ );
750
+
751
+ const header =
752
+ `/**\n` +
753
+ ` * ${GENERATED_MARKER}.\n` +
754
+ ` * No edites a mano: re-ejecuta el instalador de Fiodos para regenerarlo.\n` +
755
+ ` * Lee ${DOC_BASENAME} (misma carpeta) para saber qué cablea y por qué.\n` +
756
+ ` *\n` +
757
+ ` * SEGURIDAD: este archivo solo LLAMA a tus funciones. Nunca decide si una\n` +
758
+ ` * acción sensible se ejecuta — el motor de Fiodos aplica las confirmaciones\n` +
759
+ ` * del manifiesto (requireConfirmation / voiceConfirmation) ANTES de invocar\n` +
760
+ ` * cualquier handler de aquí.\n` +
761
+ ` */\n`;
762
+
763
+ const decl = esm
764
+ ? `export const ${REGISTRY_EXPORT}${ts ? ': FiodosRegistries' : ''} = {`
765
+ : `const ${REGISTRY_EXPORT}${ts ? ': FiodosRegistries' : ''} = {`;
766
+
767
+ const body =
768
+ `${header}\n` +
769
+ `${importLines.join('\n')}\n\n` +
770
+ (typeDefs ? `${typeDefs}\n` : '') +
771
+ `${decl}\n` +
772
+ ` handlers: {\n` +
773
+ `${handlerText}\n` +
774
+ (manualNotes.length ? `${manualNotes.join('\n')}\n` : '') +
775
+ ` },\n` +
776
+ ` // Los idempotency checkers leen el estado de tu app; impleméntalos a mano si\n` +
777
+ ` // alguna acción declara idempotencyCheck (ver ${DOC_BASENAME}).\n` +
778
+ ` idempotencyCheckers: {},\n` +
779
+ `};\n` +
780
+ (esm ? '' : `\nmodule.exports = { ${REGISTRY_EXPORT} };\n`);
781
+
782
+ return body;
783
+ }
784
+
785
+ /**
786
+ * The bridge holder module: a tiny module-level registry the component writes
787
+ * its LIVE in-scope functions into (registerFiodosBridge) and the generated
788
+ * handlers read from (getFiodosBridge). This is what lets a standalone module
789
+ * reach component-local state without ever creating a detached instance.
790
+ */
791
+ function buildBridgeModule(opts = {}) {
792
+ const ts = opts.ts !== false;
793
+ const esm = opts.esm !== false;
794
+ const header =
795
+ `/**\n` +
796
+ ` * ${GENERATED_MARKER} (bridge holder).\n` +
797
+ ` * No edites a mano. Tu componente registra aquí sus funciones reales y los\n` +
798
+ ` * handlers generados las invocan. Es lo que conecta el agente con el estado\n` +
799
+ ` * que vive dentro de tus componentes, sin crear instancias desconectadas.\n` +
800
+ ` */\n`;
801
+ if (ts) {
802
+ return (
803
+ `${header}\n` +
804
+ `type FiodosBridge = Record<string, (args: any) => any>;\n\n` +
805
+ `let __fyodosBridge: FiodosBridge = {};\n\n` +
806
+ `export function registerFiodosBridge(fns: FiodosBridge): void {\n` +
807
+ ` __fyodosBridge = { ...__fyodosBridge, ...fns };\n` +
808
+ `}\n\n` +
809
+ `export function getFiodosBridge(): FiodosBridge {\n` +
810
+ ` return __fyodosBridge;\n` +
811
+ `}\n`
812
+ );
813
+ }
814
+ if (esm) {
815
+ return (
816
+ `${header}\n` +
817
+ `let __fyodosBridge = {};\n\n` +
818
+ `export function registerFiodosBridge(fns) {\n` +
819
+ ` __fyodosBridge = { ...__fyodosBridge, ...fns };\n` +
820
+ `}\n\n` +
821
+ `export function getFiodosBridge() {\n` +
822
+ ` return __fyodosBridge;\n` +
823
+ `}\n`
824
+ );
825
+ }
826
+ return (
827
+ `${header}\n` +
828
+ `let __fyodosBridge = {};\n\n` +
829
+ `function registerFiodosBridge(fns) {\n` +
830
+ ` __fyodosBridge = { ...__fyodosBridge, ...fns };\n` +
831
+ `}\n\n` +
832
+ `function getFiodosBridge() {\n` +
833
+ ` return __fyodosBridge;\n` +
834
+ `}\n\n` +
835
+ `module.exports = { registerFiodosBridge, getFiodosBridge };\n`
836
+ );
837
+ }
838
+
839
+ /**
840
+ * Plan the edits to the user's own component files for all BRIDGE entries.
841
+ * Groups bridge entries by component file and produces, per file:
842
+ * · the import line for registerFiodosBridge (+ any extra imports invoke needs),
843
+ * · a single registerFiodosBridge({...}) block,
844
+ * · the anchor after which the block is inserted (the one occurring LAST in
845
+ * the file among the group, so every referenced symbol is already defined).
846
+ * The actual mutation/idempotence/backup happens in applyComponentEdits.
847
+ */
848
+ function planComponentEdits(appRoot, plan, opts = {}) {
849
+ const ts = opts.ts !== false;
850
+ const byFile = new Map();
851
+ for (const e of plan.auto) {
852
+ if (e.kind !== 'bridge') continue;
853
+ if (!byFile.has(e.bridge.file)) byFile.set(e.bridge.file, []);
854
+ byFile.get(e.bridge.file).push(e);
855
+ }
856
+
857
+ const edits = [];
858
+ for (const [file, entries] of byFile) {
859
+ const content = readFileSafe(appRoot, file) || '';
860
+ // Pick the anchor that appears LAST in the file (all symbols defined by then).
861
+ let anchor = entries[0].bridge.anchor;
862
+ let bestIdx = content.indexOf(anchor);
863
+ for (const e of entries) {
864
+ const idx = content.indexOf(e.bridge.anchor);
865
+ if (idx > bestIdx) {
866
+ bestIdx = idx;
867
+ anchor = e.bridge.anchor;
868
+ }
869
+ }
870
+
871
+ const argType = ts ? ': any' : '';
872
+ const classField = entries.some((e) => e.bridge.scope === 'class-field');
873
+ const methods = entries.map((e) => {
874
+ const inv = e.bridge.invoke;
875
+ return ` ${JSON.stringify(e.bridge.method)}: (args${argType}) => (${inv}),`;
876
+ });
877
+
878
+ // Imports: registerFiodosBridge + any extra the invokes need (deduped).
879
+ const importPathToBridge = entries[0].bridge.importPathToBridge;
880
+ const importLines = [`import { registerFiodosBridge } from '${importPathToBridge}';`];
881
+ const seen = new Set();
882
+ for (const e of entries) {
883
+ for (const imp of e.bridge.imports || []) {
884
+ const key = `${imp.kind}:${imp.name}:${imp.importPath}`;
885
+ if (seen.has(key)) continue;
886
+ seen.add(key);
887
+ if (imp.kind === 'default') importLines.push(`import ${imp.name} from '${imp.importPath}';`);
888
+ else if (imp.kind === 'namespace') importLines.push(`import * as ${imp.name} from '${imp.importPath}';`);
889
+ else importLines.push(`import { ${imp.name} } from '${imp.importPath}';`);
890
+ }
891
+ }
892
+
893
+ // In a class body a bare call is a syntax error; bind it to a field so the
894
+ // arrow functions capture the live `this` (the real injected instance).
895
+ const registerCall = classField
896
+ ? `private __fyodosBridge = registerFiodosBridge({\n${methods.join('\n')}\n});`
897
+ : `registerFiodosBridge({\n${methods.join('\n')}\n});`;
898
+ const registerBlock = `${EDIT_START}\n${registerCall}\n${EDIT_END}`;
899
+
900
+ // Match the anchor's leading indentation so the inserted block stays tidy.
901
+ const lineStart = content.lastIndexOf('\n', bestIdx) + 1;
902
+ const indent = (content.slice(lineStart, bestIdx).match(/^\s*/) || [''])[0];
903
+
904
+ edits.push({
905
+ file,
906
+ anchor,
907
+ scope: classField ? 'class-field' : 'statement',
908
+ importBlock: `${EDIT_START}\n${importLines.join('\n')}\n${EDIT_END}`,
909
+ registerBlock: registerBlock.split('\n').map((l) => (l ? indent + l : l)).join('\n'),
910
+ intents: entries.map((e) => e.intent),
911
+ });
912
+ }
913
+ return edits;
914
+ }
915
+
916
+ // ── Document codegen ───────────────────────────────────────────────────────────
917
+
918
+ function confLabel(e) {
919
+ if (!e.requireConfirmation) return 'no requiere confirmación';
920
+ const mode = e.voiceConfirmation ? ` (voz: ${e.voiceConfirmation})` : '';
921
+ return `requiere confirmación${mode}`;
922
+ }
923
+
924
+ function buildHandlerDoc(plan, ctx = {}) {
925
+ const {
926
+ appName = 'tu app', framework = 'web', registryRel = '', mountSnippet = '',
927
+ bridgeRel = '', edits = [], verification = null,
928
+ } = ctx;
929
+ const L = [];
930
+ L.push('# Cableado de acciones de Fiodos (handler wiring)');
931
+ L.push('');
932
+ L.push(
933
+ 'Este documento describe **exactamente** qué hará Fiodos para conectar las ' +
934
+ 'acciones detectadas en tu app con las funciones reales que las ejecutan. ' +
935
+ '**Nada se aplica hasta que aceptes en el terminal.**',
936
+ );
937
+ L.push('');
938
+ const review = plan.review || plan.manual || [];
939
+ const aiCount = plan.auto.filter((e) => e.source === 'ai').length;
940
+ L.push(`- App: **${appName}**`);
941
+ L.push(`- Framework detectado: **${framework}**`);
942
+ L.push(`- Acciones que se cablearán automáticamente: **${plan.auto.length}** ` +
943
+ `(${aiCount} cableadas por la IA, ${plan.auto.length - aiCount} por el detector mecánico de respaldo)`);
944
+ L.push(`- Acciones que necesitan revisión: **${review.length}**`);
945
+ L.push('');
946
+ L.push('## ¿Cómo se genera este cableado?');
947
+ L.push('');
948
+ L.push(
949
+ 'La **misma IA** que analizó tu código para detectar las acciones también propuso ' +
950
+ 'cómo ejecutarlas: qué función/módulo real llamar y cómo mapear los parámetros. ' +
951
+ 'Después, un **verificador mecánico** comprueba que cada símbolo referenciado ' +
952
+ 'existe de verdad en tu código antes de escribir nada. Si la IA propone algo que ' +
953
+ 'el verificador no encuentra, esa acción queda **para revisión**, nunca se cablea a ciegas.',
954
+ );
955
+ L.push('');
956
+ if (Array.isArray(verification) && verification.length) {
957
+ L.push('## Verificación automática (efecto real + reintentos)');
958
+ L.push('');
959
+ L.push(
960
+ 'Tras tu "yes", la instalación **no se fía** de que el cableado compile: por cada ' +
961
+ 'acción ejecuta un bucle **cablear → verificar → diagnosticar → corregir** sin ' +
962
+ 'intervención humana. La verificación comprueba (1) que el proyecto **sigue ' +
963
+ 'compilando** con esa acción cableada y, cuando es seguro simularlo, (2) que al ' +
964
+ 'invocar el handler **el estado real cambia** (la tarea se añade, etc.). Si una ' +
965
+ 'acción falla, la IA recibe el **error concreto** y reintenta con un cableado ' +
966
+ 'corregido (hasta varios intentos). Una acción solo se marca **lista** cuando pasa ' +
967
+ 'la verificación; si tras los reintentos no se logra, se **revierte solo esa ' +
968
+ 'acción** y se marca abajo como "no se pudo cablear" — nunca se deja fingiendo.',
969
+ );
970
+ L.push('');
971
+ L.push('| Acción (intent) | Resultado | Intentos | Verificación | Detalle |');
972
+ L.push('| --- | --- | --- | --- | --- |');
973
+ for (const v of verification) {
974
+ const res = v.status === 'ready' ? '✅ lista' : '❌ no se pudo';
975
+ let lvl = '—';
976
+ if (v.status === 'ready') {
977
+ lvl = v.level === 'effect' ? 'efecto real (UI)'
978
+ : v.level === 'unverifiable' ? '⚠️ efecto NO verificable (revisar a mano)'
979
+ : 'compilación';
980
+ }
981
+ const detail = String(v.detail || v.error || '').replace(/\|/g, '\\|').slice(0, 160);
982
+ L.push(`| \`${v.intent}\` | ${res} | ${v.attempts} | ${lvl} | ${detail} |`);
983
+ }
984
+ L.push('');
985
+ L.push('_"efecto real (UI)" = se montó el componente real en un DOM de prueba (jsdom) con ' +
986
+ 'la herramienta estándar del framework (React: react-dom; Vue: vue + @vue/compiler-sfc), ' +
987
+ 'se invocó el handler cableado y se **observó el cambio en la UI/estado** (la tarea aparece, ' +
988
+ 'el item se alterna, etc.). Para la capa de datos (Dexie/store) el efecto se observa ejecutando ' +
989
+ 'la acción contra una instancia real. "compilación" = compila y el símbolo real existe y se ' +
990
+ 'invoca, pero el efecto no pudo simularse de forma segura aquí. "⚠️ efecto NO verificable" = ' +
991
+ 'el cableado se aplicó pero el efecto real no se pudo confirmar automáticamente (componente no ' +
992
+ 'montable en aislamiento, o acción sensible que no se dispara) — **revisar/probar a mano**. Las ' +
993
+ 'acciones sensibles (con confirmación) **nunca** se disparan en la prueba._');
994
+ L.push('');
995
+ }
996
+
997
+ L.push('## ¿Por qué es necesario?');
998
+ L.push('');
999
+ L.push(
1000
+ 'El orbe ya está montado, pero el agente no puede **ejecutar** acciones hasta ' +
1001
+ 'que cada acción del manifiesto esté conectada a la función real de tu app. ' +
1002
+ 'Este cableado es ese puente: traduce cada acción (p. ej. `addToCart`) en una ' +
1003
+ 'llamada a tu función (p. ej. `useShop.getState().addToCart(...)`).',
1004
+ );
1005
+ L.push('');
1006
+ const bridgeEntries = plan.auto.filter((e) => e.kind === 'bridge');
1007
+ L.push('## Qué archivos se crean / editan');
1008
+ L.push('');
1009
+ L.push(`- **CREA** \`${registryRel}\` — el registro de handlers generado.`);
1010
+ if (bridgeEntries.length && bridgeRel) {
1011
+ L.push(`- **CREA** \`${bridgeRel}\` — el "puente" donde tu componente registra sus funciones reales.`);
1012
+ }
1013
+ if (edits.length) {
1014
+ L.push('- **EDITA los siguientes archivos TUYOS** (solo añade líneas marcadas con ' +
1015
+ '`FYODOS:BRIDGE`, reversibles):');
1016
+ for (const ed of edits) L.push(` - \`${ed.file}\` — registra: ${ed.intents.map((i) => `\`${i}\``).join(', ')}`);
1017
+ } else {
1018
+ L.push('- **No se edita ningún archivo existente tuyo** (todas las acciones eran ' +
1019
+ 'alcanzables desde un módulo).');
1020
+ }
1021
+ L.push('- Para activarlo, pasa el registro generado a `<FiodosAgent/>` (ver "Cómo activarlo").');
1022
+ L.push('');
1023
+
1024
+ if (edits.length) {
1025
+ L.push('## Cambios EXACTOS dentro de tus componentes');
1026
+ L.push('');
1027
+ L.push('Estas son las líneas que Fiodos añadirá a tus archivos (cada bloque va entre ' +
1028
+ 'marcas `FYODOS:BRIDGE:START` / `END`, así puedes quitarlas o revertirlas sin tocar ' +
1029
+ 'tu propio código):');
1030
+ L.push('');
1031
+ for (const ed of edits) {
1032
+ L.push(`### \`${ed.file}\``);
1033
+ L.push('');
1034
+ L.push('Tras la última línea `import`, se añade:');
1035
+ L.push('');
1036
+ L.push('```ts');
1037
+ L.push(ed.importBlock);
1038
+ L.push('```');
1039
+ L.push('');
1040
+ L.push(ed.file.endsWith('.vue')
1041
+ ? 'Justo antes de `</script>`, se añade:'
1042
+ : `Junto al ancla \`${(ed.anchor || '').trim().slice(0, 80)}\`, se añade:`);
1043
+ L.push('');
1044
+ L.push('```ts');
1045
+ L.push(ed.registerBlock.replace(/^\s+/gm, (s) => s)); // keep as-is
1046
+ L.push('```');
1047
+ L.push('');
1048
+ }
1049
+ }
1050
+
1051
+ if (plan.auto.length) {
1052
+ L.push(`## Acciones que se cablearán automáticamente (${plan.auto.length})`);
1053
+ L.push('');
1054
+ L.push('| Acción (intent) | Cómo se ejecuta | Dónde | Parámetros | Tipo | Seguridad |');
1055
+ L.push('| --- | --- | --- | --- | --- | --- |');
1056
+ for (const e of plan.auto) {
1057
+ const expr = e.kind === 'bridge' ? e.bridge.invoke : e.callExpr;
1058
+ const call = `\`${String(expr).replace(/\|/g, '\\|')}\``;
1059
+ const where = e.kind === 'bridge' ? `\`${e.bridge.file}\`` : `\`${e.file || '—'}\``;
1060
+ const params = e.manifestParams.length
1061
+ ? e.manifestParams.map((p) => `${p.name}${p.required ? '*' : ''}`).join(', ')
1062
+ : '—';
1063
+ const kind = e.kind === 'bridge'
1064
+ ? 'puente (edita tu componente)'
1065
+ : (e.source === 'ai' ? 'módulo (IA)' : 'módulo (mecánico)');
1066
+ L.push(`| \`${e.intent}\` | ${call} | ${where} | ${params} | ${kind} | ${confLabel(e)} |`);
1067
+ }
1068
+ L.push('');
1069
+ L.push('_`*` = parámetro requerido. Cada llamada fue propuesta por la IA leyendo tu ' +
1070
+ 'código y **confirmada por el verificador** (los símbolos existen). Los parámetros ' +
1071
+ 'se mapean **por nombre**, nunca por posición._');
1072
+ L.push('');
1073
+ }
1074
+
1075
+ if (review.length) {
1076
+ L.push(`## Acciones que necesitan REVISIÓN (${review.length})`);
1077
+ L.push('');
1078
+ L.push(
1079
+ 'Para estas acciones, ni la IA propuso un cableado **verificable** ni el detector ' +
1080
+ 'mecánico de respaldo encontró la función con seguridad. Cablearlas mal sería ' +
1081
+ 'peor que dejarlas sin cablear, así que se marcan para revisión y te explicamos por qué:',
1082
+ );
1083
+ L.push('');
1084
+ for (const e of review) {
1085
+ L.push(`### \`${e.intent}\` — ${e.label}`);
1086
+ L.push('');
1087
+ L.push(`- Handler esperado: \`${e.handler}\``);
1088
+ if (e.file) L.push(`- Archivo de evidencia: \`${e.file}\``);
1089
+ L.push(`- Motivo: ${e.reason}`);
1090
+ L.push(
1091
+ `- Qué hacer: añade un handler \`${e.handler}\` en \`${registryRel}\` (o en tu ` +
1092
+ 'propio registro) que llame a la función real y devuelva ' +
1093
+ '`{ success: true }` (o `{ success: false, message }`).',
1094
+ );
1095
+ L.push('');
1096
+ }
1097
+ }
1098
+
1099
+ const confirmed = plan.entries.filter((e) => e.requireConfirmation);
1100
+ L.push('## Seguridad');
1101
+ L.push('');
1102
+ L.push(
1103
+ '- El cableado generado **solo llama** a tus funciones. **Nunca** decide si una ' +
1104
+ 'acción sensible se ejecuta: el motor de Fiodos aplica las confirmaciones del ' +
1105
+ 'manifiesto (`requireConfirmation` / `voiceConfirmation`) **antes** de invocar ' +
1106
+ 'el handler. Es imposible que el auto-cableado salte una confirmación.',
1107
+ );
1108
+ if (confirmed.length) {
1109
+ L.push('- Acciones protegidas por confirmación (el agente pedirá confirmar antes de ejecutarlas):');
1110
+ for (const e of confirmed) {
1111
+ L.push(` - \`${e.intent}\` — ${confLabel(e)}.`);
1112
+ }
1113
+ } else {
1114
+ L.push('- Ninguna de las acciones detectadas requiere confirmación en el manifiesto.');
1115
+ }
1116
+ L.push('- El cableado no lee, incrusta ni expone secretos; solo referencia nombres de');
1117
+ L.push(' función y los parámetros que el manifiesto ya declara.');
1118
+ L.push('');
1119
+
1120
+ L.push('## Cómo activarlo');
1121
+ L.push('');
1122
+ if (mountSnippet) {
1123
+ L.push('Pasa el registro generado a tu `<FiodosAgent/>`:');
1124
+ L.push('');
1125
+ L.push('```tsx');
1126
+ L.push(mountSnippet);
1127
+ L.push('```');
1128
+ } else {
1129
+ L.push('Importa `' + REGISTRY_EXPORT + '` desde el archivo generado y pásalo como ' +
1130
+ '`registries` a tu `<FiodosAgent/>`.');
1131
+ }
1132
+ L.push('');
1133
+ L.push('## Si dijiste "no"');
1134
+ L.push('');
1135
+ L.push(
1136
+ 'No se ha tocado tu código. Puedes implementar el cableado tú mismo siguiendo ' +
1137
+ 'este documento, o volver a ejecutar el instalador para aceptar el auto-cableado ' +
1138
+ '(`--wire-yes` para aceptarlo sin preguntar).',
1139
+ );
1140
+ L.push('');
1141
+ return L.join('\n');
1142
+ }
1143
+
1144
+ // ── Editing the user's own files (idempotent + reversible) ─────────────────────
1145
+
1146
+ /** Remove any previously-inserted Fiodos blocks (idempotence across re-runs). */
1147
+ function stripFiodosBlocks(content) {
1148
+ const re = new RegExp(`\\n?${esc(EDIT_START)}[\\s\\S]*?${esc(EDIT_END)}\\n?`, 'g');
1149
+ return content.replace(re, '\n');
1150
+ }
1151
+
1152
+ /** Insert an import block right after the file's last top-level import line. */
1153
+ function insertImportBlock(content, importBlock) {
1154
+ const importLineRe = /^[ \t]*import\b[^\n]*\n/gm;
1155
+ let lastEnd = -1;
1156
+ let m;
1157
+ while ((m = importLineRe.exec(content))) lastEnd = m.index + m[0].length;
1158
+ if (lastEnd === -1) {
1159
+ // No imports (rare). Put it after a leading <script ...> tag if present.
1160
+ const scriptM = content.match(/<script\b[^>]*>\s*\n/);
1161
+ if (scriptM) {
1162
+ const at = scriptM.index + scriptM[0].length;
1163
+ return content.slice(0, at) + importBlock + '\n' + content.slice(at);
1164
+ }
1165
+ return importBlock + '\n' + content;
1166
+ }
1167
+ return content.slice(0, lastEnd) + importBlock + '\n' + content.slice(lastEnd);
1168
+ }
1169
+
1170
+ /** Index of the JSX/paren `return (` or `return <` line nearest to `anchorIdx`. */
1171
+ function nearestJsxReturn(content, anchorIdx) {
1172
+ const re = /^[ \t]*return\s*[(<]/gm;
1173
+ let best = -1;
1174
+ let bestDist = Infinity;
1175
+ let m;
1176
+ while ((m = re.exec(content))) {
1177
+ const dist = Math.abs(m.index - anchorIdx);
1178
+ if (dist < bestDist) { bestDist = dist; best = m.index; }
1179
+ }
1180
+ return best;
1181
+ }
1182
+
1183
+ /**
1184
+ * Insert the registration block at a SYNTACTICALLY VALID spot:
1185
+ * · .vue files → just before the last </script>,
1186
+ * · class-field scope (Angular @Component) → right AFTER the anchor line, inside
1187
+ * the class body (the anchor is a sibling class field),
1188
+ * · statement scope (React/JS function component) → just BEFORE the JSX `return`
1189
+ * nearest the anchor, so the call is always a statement and NEVER lands inside
1190
+ * JSX (which produced `Expected "}" but found ":"`). The AI's anchor only needs
1191
+ * to locate the right component, not the exact insertion line.
1192
+ * · fallback (no JSX return: object-returning factories, etc.) → before a
1193
+ * `return` anchor, else after the anchor line.
1194
+ */
1195
+ function insertRegisterBlock(content, file, anchor, block, scope) {
1196
+ if (file.endsWith('.vue')) {
1197
+ const close = content.lastIndexOf('</script>');
1198
+ if (close !== -1) {
1199
+ const lineStart = content.lastIndexOf('\n', close) + 1;
1200
+ return content.slice(0, lineStart) + block + '\n' + content.slice(lineStart);
1201
+ }
1202
+ }
1203
+ const at = content.indexOf(anchor);
1204
+ if (at === -1) return content; // verified earlier; defensive
1205
+
1206
+ if (scope === 'class-field') {
1207
+ const lineEnd = content.indexOf('\n', at);
1208
+ const endIdx = lineEnd === -1 ? content.length : lineEnd;
1209
+ return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
1210
+ }
1211
+
1212
+ const ret = nearestJsxReturn(content, at);
1213
+ if (ret !== -1) {
1214
+ const lineStart = content.lastIndexOf('\n', ret) + 1;
1215
+ return content.slice(0, lineStart) + block + '\n' + content.slice(lineStart);
1216
+ }
1217
+
1218
+ const lineStart = content.lastIndexOf('\n', at) + 1;
1219
+ if (anchor.trim().startsWith('return')) {
1220
+ return content.slice(0, lineStart) + block + '\n' + content.slice(lineStart);
1221
+ }
1222
+ const lineEnd = content.indexOf('\n', at);
1223
+ const endIdx = lineEnd === -1 ? content.length : lineEnd;
1224
+ return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
1225
+ }
1226
+
1227
+ /**
1228
+ * Apply all planned component edits. Snapshots each file's original content
1229
+ * first (for clean revert) and is idempotent (strips prior Fiodos blocks before
1230
+ * re-inserting). Returns { backups: [{file, original}], edited: [file...] }.
1231
+ */
1232
+ function applyComponentEdits(appRoot, edits) {
1233
+ const backups = [];
1234
+ const edited = [];
1235
+ for (const edit of edits) {
1236
+ const abs = path.join(appRoot, normRel(edit.file));
1237
+ const original = fs.readFileSync(abs, 'utf8');
1238
+ backups.push({ file: edit.file, abs, original });
1239
+
1240
+ let next = stripFiodosBlocks(original);
1241
+ next = insertImportBlock(next, edit.importBlock);
1242
+ next = insertRegisterBlock(next, edit.file, edit.anchor, edit.registerBlock, edit.scope);
1243
+ fs.writeFileSync(abs, next);
1244
+ edited.push(edit.file);
1245
+ }
1246
+ return { backups, edited };
1247
+ }
1248
+
1249
+ /** Restore files to their pre-edit content and delete generated files. */
1250
+ function revertAll(backups, generatedFiles) {
1251
+ for (const b of backups || []) {
1252
+ try {
1253
+ fs.writeFileSync(b.abs, b.original);
1254
+ } catch {
1255
+ /* best effort */
1256
+ }
1257
+ }
1258
+ for (const f of generatedFiles || []) {
1259
+ try {
1260
+ if (fs.existsSync(f)) fs.rmSync(f);
1261
+ } catch {
1262
+ /* best effort */
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ // ── Consent ────────────────────────────────────────────────────────────────────
1268
+
1269
+ function askYesNo(question) {
1270
+ return new Promise((resolve) => {
1271
+ if (!process.stdin.isTTY) {
1272
+ resolve(false);
1273
+ return;
1274
+ }
1275
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
1276
+ rl.question(question, (answer) => {
1277
+ rl.close();
1278
+ resolve(/^(y|yes|s|si|sí)/i.test((answer || '').trim()));
1279
+ });
1280
+ });
1281
+ }
1282
+
1283
+ // ── Folder resolution ───────────────────────────────────────────────────────────
1284
+
1285
+ /** Where the Fiodos folder lives in the user's project (relative to appRoot). */
1286
+ function resolveFiodosDirRel(appRoot) {
1287
+ if (fs.existsSync(path.join(appRoot, 'src'))) return path.join('src', 'fyodos');
1288
+ return 'fyodos';
1289
+ }
1290
+
1291
+ function detectTsEsm(appRoot) {
1292
+ let ts = fs.existsSync(path.join(appRoot, 'tsconfig.json'));
1293
+ let esm = true;
1294
+ try {
1295
+ const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'));
1296
+ if (pkg.type === 'commonjs') esm = false;
1297
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
1298
+ if (deps.typescript) ts = true;
1299
+ } catch {
1300
+ /* defaults */
1301
+ }
1302
+ return { ts, esm };
1303
+ }
1304
+
1305
+ // ── Auto-correction loop (verify → diagnose → correct → retry, per action) ─────
1306
+
1307
+ /** Build the single verified entry for one action from a given wiring candidate. */
1308
+ function planOneAction({ appRoot, manifest, evidence, candidate, intent, fyodosDirRel }) {
1309
+ const action = (manifest.actions || []).find((a) => a.intent === intent);
1310
+ const plan = prepareHandlerPlan({
1311
+ appRoot,
1312
+ manifest: { ...manifest, actions: [action] },
1313
+ evidence,
1314
+ wiring: { [intent]: candidate },
1315
+ fyodosDirRel,
1316
+ });
1317
+ return { entry: plan.auto[0] || null, review: plan.review[0] || null };
1318
+ }
1319
+
1320
+ /** Pick the few files the corrector needs (keeps the re-prompt small/cheap). */
1321
+ function pickRelevantFiles(files, evidence, candidate, intent) {
1322
+ if (!Array.isArray(files) || !files.length) return [];
1323
+ const wanted = new Set();
1324
+ const ev = (evidence.actions || {})[intent];
1325
+ if (ev && ev.file) wanted.add(normRel(ev.file));
1326
+ if (candidate) {
1327
+ if (candidate.bridge && candidate.bridge.file) wanted.add(normRel(candidate.bridge.file));
1328
+ for (const imp of candidate.imports || []) if (imp && imp.from) wanted.add(normRel(imp.from));
1329
+ for (const imp of (candidate.bridge && candidate.bridge.imports) || []) if (imp && imp.from) wanted.add(normRel(imp.from));
1330
+ for (const r of candidate.requiredSymbols || []) if (r && r.file) wanted.add(normRel(r.file));
1331
+ }
1332
+ const picked = files.filter((f) => wanted.has(normRel(f.rel)));
1333
+ return picked.length ? picked : files.slice(0, 4);
1334
+ }
1335
+
1336
+ /** Write ONLY one entry (registry + bridge + its edit) for isolated verification. */
1337
+ function writeIsolated({ appRoot, fyodosDirAbs, entry, ts, esm, ext }) {
1338
+ const onePlan = { auto: [entry], review: [], manual: [], entries: [entry] };
1339
+ const registryAbs = path.join(fyodosDirAbs, `${REGISTRY_BASENAME}${ext}`);
1340
+ const bridgeAbs = path.join(fyodosDirAbs, `${BRIDGE_BASENAME}${ext}`);
1341
+ const generated = [registryAbs];
1342
+ if (entry.kind === 'bridge') {
1343
+ fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
1344
+ generated.push(bridgeAbs);
1345
+ }
1346
+ fs.writeFileSync(registryAbs, buildRegistryModule(onePlan, { ts, esm }));
1347
+ const edits = planComponentEdits(appRoot, onePlan, { ts });
1348
+ const { backups } = applyComponentEdits(appRoot, edits);
1349
+ return { backups, generated, touched: [...edits.map((e) => e.file), registryAbs, bridgeAbs] };
1350
+ }
1351
+
1352
+ /**
1353
+ * The install-time loop. For EVERY action: build its wiring candidate, write it
1354
+ * in isolation, verify (compile regression + effect probe), and on failure ask
1355
+ * the AI to correct it with the concrete error — up to maxAttempts. An action is
1356
+ * only "ready" once it passes; otherwise it is dropped (its edits reverted) and
1357
+ * reported honestly. Finally the ready entries are written together and a last
1358
+ * build confirms the combination did not regress.
1359
+ */
1360
+ async function runAutoCorrectionLoop(ctx) {
1361
+ const {
1362
+ appRoot, fyodosDirRel, fyodosDirAbs, manifest, evidence, wiringMap,
1363
+ ts, esm, ext, testRunner, corrector, files, framework,
1364
+ maxAttempts = 3, tag, dim, reset, registryRel,
1365
+ } = ctx;
1366
+
1367
+ const log = (m) => console.error(`${tag} · ${dim || ''}${m}${reset || ''}`);
1368
+ log('verificando y auto-corrigiendo cada acción (puede tardar)…');
1369
+ const baseline = await testRunner(appRoot, { framework });
1370
+
1371
+ const ready = [];
1372
+ const dropped = [];
1373
+ const attemptsLog = [];
1374
+
1375
+ for (const action of manifest.actions || []) {
1376
+ const intent = action.intent;
1377
+ let attempt = 0;
1378
+ let lastError = '';
1379
+ let resolved = null;
1380
+ const history = [];
1381
+
1382
+ while (attempt < maxAttempts) {
1383
+ attempt += 1;
1384
+ const { entry, review } = planOneAction({ appRoot, manifest, evidence, candidate: wiringMap[intent], intent, fyodosDirRel });
1385
+
1386
+ if (!entry) {
1387
+ lastError = (review && review.reason) || 'no verificable mecánicamente';
1388
+ history.push({ attempt, stage: 'mechanical', ok: false, error: lastError });
1389
+ } else {
1390
+ const iso = writeIsolated({ appRoot, fyodosDirAbs, entry, ts, esm, ext });
1391
+ let ok = true;
1392
+ let stage = 'compile';
1393
+ try {
1394
+ const after = await testRunner(appRoot, { framework });
1395
+ const reg = compileRegressed(baseline.output, after.output, iso.touched);
1396
+ if (!reg.ok) { ok = false; lastError = `compilación: ${reg.newErrors.join(' | ').slice(0, 600)}`; }
1397
+ if (ok) {
1398
+ const eff = await probeEffect(appRoot, entry, { fyodosDirRel, framework, sources: files, sensitive: entry.requireConfirmation });
1399
+ if (eff.status === 'fail') { ok = false; stage = 'effect'; lastError = `efecto: ${eff.detail}`; }
1400
+ else {
1401
+ const level = eff.status === 'effect-pass' ? 'effect'
1402
+ : eff.status === 'unverifiable' ? 'unverifiable'
1403
+ : 'compile';
1404
+ resolved = { entry, level, detail: eff.detail };
1405
+ }
1406
+ }
1407
+ } catch (err) {
1408
+ ok = false; lastError = `verificación lanzó: ${err && err.message}`;
1409
+ } finally {
1410
+ revertAll(iso.backups, iso.generated);
1411
+ }
1412
+ history.push({ attempt, stage, ok, error: ok ? undefined : lastError, level: resolved && resolved.level, detail: resolved && resolved.detail });
1413
+ if (resolved) break;
1414
+ }
1415
+
1416
+ if (attempt >= maxAttempts || typeof corrector !== 'function') break;
1417
+ log(`acción '${intent}' falló (intento ${attempt}): ${lastError.slice(0, 120)} → pidiendo corrección a la IA…`);
1418
+ try {
1419
+ const relevantFiles = pickRelevantFiles(files, evidence, wiringMap[intent], intent);
1420
+ const corrected = await corrector({ action, intent, evidence: (evidence && evidence.actions) || {}, relevantFiles, prevWiring: wiringMap[intent], errorText: lastError, attempt });
1421
+ if (corrected && corrected[intent]) wiringMap[intent] = corrected[intent];
1422
+ else { history.push({ attempt, stage: 'correct', ok: false, error: 'la IA no devolvió cableado corregido' }); break; }
1423
+ } catch (err) {
1424
+ history.push({ attempt, stage: 'correct', ok: false, error: `corrector falló: ${err && err.message}` });
1425
+ break;
1426
+ }
1427
+ }
1428
+
1429
+ if (resolved) {
1430
+ ready.push(resolved.entry);
1431
+ attemptsLog.push({ intent, attempts: attempt, status: 'ready', level: resolved.level, detail: resolved.detail, history });
1432
+ log(`✓ '${intent}' verificada en ${attempt} intento(s) [${resolved.level === 'effect' ? 'efecto real' : 'compilación'}].`);
1433
+ } else {
1434
+ dropped.push({ ...action, intent, reason: lastError, attempts: attempt, handler: action.handler || intent, label: action.label || intent, manifestParams: Object.entries(action.parameters || {}).map(([name, spec]) => ({ name, required: spec && spec.required === true })) });
1435
+ attemptsLog.push({ intent, attempts: attempt, status: 'dropped', error: lastError, history });
1436
+ log(`✗ '${intent}' no se pudo cablear tras ${attempt} intento(s): ${lastError.slice(0, 140)}`);
1437
+ }
1438
+ }
1439
+
1440
+ return { baseline, ready, dropped, attemptsLog };
1441
+ }
1442
+
1443
+ // ── Public API ───────────────────────────────────────────────────────────────
1444
+
1445
+ /**
1446
+ * Prepare + document + (on consent) apply the handler wiring.
1447
+ * Returns { status, docPath?, registryFile?, autoCount, manualCount, plan }.
1448
+ * status: 'no-actions' | 'manual-only' | 'declined' | 'declined-noninteractive'
1449
+ * | 'applied' | 'skipped' | 'failed'
1450
+ */
1451
+ async function wireHandlers(appRoot, opts = {}) {
1452
+ const {
1453
+ manifest,
1454
+ evidence = {},
1455
+ wiring = {},
1456
+ colors = {},
1457
+ assumeYes = false,
1458
+ noWire = false,
1459
+ framework = 'web',
1460
+ appName,
1461
+ registryExt: registryExtOpt,
1462
+ esm: esmOpt,
1463
+ ts: tsOpt,
1464
+ runTest = false,
1465
+ revertOnFailure = true,
1466
+ testRunner = null,
1467
+ corrector = null,
1468
+ files = [],
1469
+ maxAttempts = 3,
1470
+ } = opts;
1471
+ const { blue, cyan, dim, reset } = colors;
1472
+ const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
1473
+
1474
+ if (noWire) return { status: 'skipped' };
1475
+ const actions = (manifest && manifest.actions) || [];
1476
+ if (!actions.length) return { status: 'no-actions' };
1477
+
1478
+ const fyodosDirRel = resolveFiodosDirRel(appRoot);
1479
+ const fyodosDirAbs = path.join(appRoot, fyodosDirRel);
1480
+ const detected = detectTsEsm(appRoot);
1481
+ const ts = tsOpt != null ? tsOpt : detected.ts;
1482
+ const esm = esmOpt != null ? esmOpt : detected.esm;
1483
+ const ext = registryExtOpt || (ts ? '.ts' : esm ? '.js' : '.cjs');
1484
+
1485
+ const plan = prepareHandlerPlan({ appRoot, manifest, evidence, wiring, fyodosDirRel });
1486
+ const hasBridge = plan.auto.some((e) => e.kind === 'bridge');
1487
+ const componentEdits = planComponentEdits(appRoot, plan, { ts });
1488
+ const registryRel = path.join(fyodosDirRel, `${REGISTRY_BASENAME}${ext}`).split(path.sep).join('/');
1489
+ const bridgeRel = path.join(fyodosDirRel, `${BRIDGE_BASENAME}${ext}`).split(path.sep).join('/');
1490
+ const registryImport = `./${REGISTRY_BASENAME}`;
1491
+
1492
+ const mountSnippet =
1493
+ `import { ${REGISTRY_EXPORT} } from '${registryImport}';\n` +
1494
+ `\n` +
1495
+ `<FiodosAgent\n` +
1496
+ ` apiKey={/* tu API key */}\n` +
1497
+ ` registries={${REGISTRY_EXPORT}}\n` +
1498
+ `/>`;
1499
+
1500
+ // 1) ALWAYS write the document first, so the developer can read before consenting.
1501
+ fs.mkdirSync(fyodosDirAbs, { recursive: true });
1502
+ const docAbs = path.join(fyodosDirAbs, DOC_BASENAME);
1503
+ const docText = buildHandlerDoc(plan, {
1504
+ appName: appName || path.basename(appRoot),
1505
+ framework,
1506
+ registryRel,
1507
+ bridgeRel,
1508
+ edits: componentEdits,
1509
+ mountSnippet,
1510
+ });
1511
+ fs.writeFileSync(docAbs, docText);
1512
+ const docRel = path.relative(appRoot, docAbs);
1513
+
1514
+ // 2) Nothing can be wired with confidence → leave the doc, explain, never guess.
1515
+ // BUT when an auto-corrector is available the initial plan being empty is not
1516
+ // terminal: the loop will re-prompt the AI to FIX each action, so we proceed
1517
+ // to consent and let the loop decide what ends up wired.
1518
+ const loopEnabled = typeof corrector === 'function' && runTest && testRunner;
1519
+ if (!plan.auto.length && !loopEnabled) {
1520
+ console.error(
1521
+ `\n${tag} · ${dim || ''}preparé el cableado de acciones, pero ninguna de las ` +
1522
+ `${plan.manual.length} acción(es) se puede conectar con confianza.${reset || ''}`,
1523
+ );
1524
+ console.error(
1525
+ `${tag} · revisa qué hace falta y cómo hacerlo a mano en: ${docRel}`,
1526
+ );
1527
+ return { status: 'manual-only', docPath: docAbs, autoCount: 0, manualCount: plan.manual.length, plan };
1528
+ }
1529
+
1530
+ // 3) Second, separate confirmation (after the orb's), referencing the doc.
1531
+ const touchNote = componentEdits.length
1532
+ ? ` Se editarán ${componentEdits.length} archivo(s) TUYO(s) (con marcas reversibles).`
1533
+ : '';
1534
+ const prompt =
1535
+ `\n${tag} · El cableado de acciones necesario para que el agente ejecute funciones ` +
1536
+ `está listo (${plan.auto.length} automática(s), ${plan.manual.length} en revisión).${touchNote}\n` +
1537
+ `${tag} · Puedes revisar exactamente qué cambios se harán en tu código en este archivo:\n` +
1538
+ ` ${docRel}\n` +
1539
+ `${tag} · ¿Aceptas aplicar estos cambios después de revisarlo? [yes/no] `;
1540
+
1541
+ if (!assumeYes && !process.stdin.isTTY) {
1542
+ console.error(prompt.trimEnd());
1543
+ console.error(
1544
+ `${tag} · ${dim || ''}terminal no interactivo: no toco tu código. El documento ` +
1545
+ `queda en ${docRel}. Re-ejecuta con --wire-yes para aplicarlo.${reset || ''}`,
1546
+ );
1547
+ return { status: 'declined-noninteractive', docPath: docAbs, autoCount: plan.auto.length, manualCount: plan.manual.length, plan };
1548
+ }
1549
+
1550
+ const yes = assumeYes || (await askYesNo(prompt));
1551
+ if (!yes) {
1552
+ console.error(
1553
+ `${tag} · ${dim || ''}no modifico tu código. El documento queda en ${docRel} ` +
1554
+ `por si quieres cablearlo a mano o re-ejecutar con --wire-yes.${reset || ''}`,
1555
+ );
1556
+ return { status: 'declined', docPath: docAbs, autoCount: plan.auto.length, manualCount: plan.manual.length, plan };
1557
+ }
1558
+
1559
+ // 4) Baseline: BEFORE touching anything, check whether the app already builds.
1560
+ // This lets us tell "we broke it" (revert) from "it was already broken"
1561
+ // (don't revert a good wiring over a pre-existing failure).
1562
+ let baseline = null;
1563
+ if (runTest && testRunner) {
1564
+ console.error(`${tag} · ${dim || ''}comprobando el estado del build ANTES de cablear (baseline)…${reset || ''}`);
1565
+ try {
1566
+ baseline = await testRunner(appRoot, { framework });
1567
+ } catch (err) {
1568
+ baseline = { ok: false, stage: 'runner', output: err && err.message };
1569
+ }
1570
+ }
1571
+
1572
+ const registryAbs = path.join(fyodosDirAbs, `${REGISTRY_BASENAME}${ext}`);
1573
+ const bridgeAbs = path.join(fyodosDirAbs, `${BRIDGE_BASENAME}${ext}`);
1574
+
1575
+ // 4b) AUTO-CORRECTION LOOP. When a corrector (AI re-prompt) is available we run
1576
+ // the verify→diagnose→correct→retry loop per action. Only actions that PASS
1577
+ // verification are written; the rest are reverted and reported honestly.
1578
+ if (corrector && runTest && testRunner) {
1579
+ const wiringMap = { ...wiring };
1580
+ const loop = await runAutoCorrectionLoop({
1581
+ appRoot, fyodosDirRel, fyodosDirAbs, manifest, evidence, wiringMap,
1582
+ ts, esm, ext, testRunner, corrector, files, framework, maxAttempts,
1583
+ tag, dim, reset, registryRel, baseline,
1584
+ });
1585
+
1586
+ const finalAuto = loop.ready;
1587
+ const droppedReview = loop.dropped.map((d) => ({
1588
+ ...d,
1589
+ confidence: 'review',
1590
+ reason: `no se pudo cablear automáticamente tras ${d.attempts} intento(s): ${d.reason}`,
1591
+ }));
1592
+ const finalPlan = {
1593
+ auto: finalAuto, review: droppedReview, manual: droppedReview,
1594
+ entries: [...finalAuto, ...droppedReview],
1595
+ };
1596
+ const finalEdits = planComponentEdits(appRoot, finalPlan, { ts });
1597
+ const finalHasBridge = finalAuto.some((e) => e.kind === 'bridge');
1598
+
1599
+ // Rewrite the doc with the REAL verification results (attempts, levels, drops).
1600
+ fs.writeFileSync(docAbs, buildHandlerDoc(finalPlan, {
1601
+ appName: appName || path.basename(appRoot),
1602
+ framework, registryRel, bridgeRel, edits: finalEdits, mountSnippet,
1603
+ verification: loop.attemptsLog,
1604
+ }));
1605
+
1606
+ if (!finalAuto.length) {
1607
+ return {
1608
+ status: 'manual-only', docPath: docAbs, autoCount: 0,
1609
+ manualCount: droppedReview.length, plan: finalPlan,
1610
+ verification: loop.attemptsLog, dropped: loop.dropped, baseline,
1611
+ };
1612
+ }
1613
+
1614
+ const generatedFiles = [registryAbs];
1615
+ let backups = [];
1616
+ try {
1617
+ if (finalHasBridge) {
1618
+ fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
1619
+ generatedFiles.push(bridgeAbs);
1620
+ }
1621
+ fs.writeFileSync(registryAbs, buildRegistryModule(finalPlan, { ts, esm }));
1622
+ ({ backups } = applyComponentEdits(appRoot, finalEdits));
1623
+ } catch (err) {
1624
+ revertAll(backups, generatedFiles);
1625
+ return { status: 'failed', docPath: docAbs, error: err, plan: finalPlan, verification: loop.attemptsLog };
1626
+ }
1627
+
1628
+ // Final combined build (each action passed in isolation; confirm the union).
1629
+ let test;
1630
+ try {
1631
+ test = await testRunner(appRoot, { framework });
1632
+ } catch (err) {
1633
+ test = { ok: false, stage: 'runner', output: err && err.message };
1634
+ }
1635
+ const reg = compileRegressed((baseline && baseline.output) || '', (test && test.output) || '',
1636
+ [...finalEdits.map((e) => e.file), 'handlers.generated', 'bridge']);
1637
+ const combinedRegression = !reg.ok && baseline && baseline.ok;
1638
+ if (combinedRegression && revertOnFailure) {
1639
+ revertAll(backups, generatedFiles);
1640
+ return {
1641
+ status: 'reverted', docPath: docAbs, plan: finalPlan, test,
1642
+ verification: loop.attemptsLog, dropped: loop.dropped, baseline,
1643
+ };
1644
+ }
1645
+ return {
1646
+ status: reg.ok ? 'applied' : 'applied-untested',
1647
+ docPath: docAbs, registryFile: registryAbs, registryRel,
1648
+ bridgeFile: finalHasBridge ? bridgeAbs : null, mountSnippet,
1649
+ autoCount: finalAuto.length, manualCount: droppedReview.length,
1650
+ editedFiles: finalEdits.map((e) => e.file), plan: finalPlan,
1651
+ verification: loop.attemptsLog, dropped: loop.dropped,
1652
+ baseline, test, preexistingFailure: baseline && baseline.ok === false && baseline.stage !== 'skipped-no-deps',
1653
+ };
1654
+ }
1655
+
1656
+ // 5) Apply (single-shot path, no corrector): bridge module (if any) + registry +
1657
+ // the edits inside the user's own components. Everything idempotent; component
1658
+ // files are snapshotted so we can revert cleanly if the post-wiring test fails.
1659
+ const generatedFiles = [registryAbs];
1660
+ let backups = [];
1661
+ try {
1662
+ if (hasBridge) {
1663
+ fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
1664
+ generatedFiles.push(bridgeAbs);
1665
+ }
1666
+ fs.writeFileSync(registryAbs, buildRegistryModule(plan, { ts, esm }));
1667
+ if (!fs.readFileSync(registryAbs, 'utf8').includes(GENERATED_MARKER)) {
1668
+ throw new Error('post-write verification failed');
1669
+ }
1670
+ ({ backups } = applyComponentEdits(appRoot, componentEdits));
1671
+ } catch (err) {
1672
+ revertAll(backups, generatedFiles);
1673
+ return { status: 'failed', docPath: docAbs, error: err, plan };
1674
+ }
1675
+
1676
+ const baseResult = {
1677
+ docPath: docAbs,
1678
+ registryFile: registryAbs,
1679
+ registryRel,
1680
+ bridgeFile: hasBridge ? bridgeAbs : null,
1681
+ mountSnippet,
1682
+ autoCount: plan.auto.length,
1683
+ manualCount: plan.manual.length,
1684
+ editedFiles: componentEdits.map((e) => e.file),
1685
+ baseline,
1686
+ plan,
1687
+ };
1688
+
1689
+ // 6) Post-wiring test: make sure WE did not break the app. We only revert when
1690
+ // the app built BEFORE and fails AFTER (a regression we caused). If it was
1691
+ // already broken, we keep the wiring and flag it (can't verify via build).
1692
+ if (runTest && testRunner) {
1693
+ if (baseline && baseline.ok === false && baseline.stage !== 'skipped-no-deps') {
1694
+ return { status: 'applied-untested', ...baseResult, test: baseline, preexistingFailure: true };
1695
+ }
1696
+ console.error(`${tag} · ${dim || ''}probando que la app sigue compilando tras el cableado…${reset || ''}`);
1697
+ let test;
1698
+ try {
1699
+ test = await testRunner(appRoot, { framework });
1700
+ } catch (err) {
1701
+ test = { ok: false, stage: 'runner', output: err && err.message };
1702
+ }
1703
+ // Regression we caused: built before, fails now → revert.
1704
+ if (!test.ok && baseline && baseline.ok && revertOnFailure) {
1705
+ revertAll(backups, generatedFiles);
1706
+ return { status: 'reverted', ...baseResult, test };
1707
+ }
1708
+ return { status: test.ok ? 'applied' : 'applied-untested', ...baseResult, test };
1709
+ }
1710
+
1711
+ return { status: 'applied', ...baseResult };
1712
+ }
1713
+
1714
+ function reportHandlerResult(result, colors = {}) {
1715
+ if (!result) return;
1716
+ const { blue, cyan, dim, reset } = colors;
1717
+ const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
1718
+ switch (result.status) {
1719
+ case 'applied':
1720
+ case 'applied-untested': {
1721
+ const rel = result.registryRel || result.registryFile;
1722
+ console.error(`${tag} · ✓ cableado de handlers aplicado → ${rel} (${result.autoCount} acción(es)).`);
1723
+ if (Array.isArray(result.verification) && result.verification.length) {
1724
+ const ready = result.verification.filter((v) => v.status === 'ready');
1725
+ const corrected = ready.filter((v) => v.attempts > 1).length;
1726
+ const firstTry = ready.length - corrected;
1727
+ const eff = ready.filter((v) => v.level === 'effect').length;
1728
+ console.error(`${tag} · ${dim || ''}verificación: ${ready.length} acción(es) listas (${firstTry} a la primera, ${corrected} tras auto-corrección, ${eff} con efecto real comprobado).${reset || ''}`);
1729
+ }
1730
+ if (result.editedFiles && result.editedFiles.length) {
1731
+ console.error(`${tag} · ${dim || ''}puentes añadidos (reversibles) en: ${result.editedFiles.join(', ')}.${reset || ''}`);
1732
+ }
1733
+ if (Array.isArray(result.dropped) && result.dropped.length) {
1734
+ console.error(`${tag} · ${dim || ''}${result.dropped.length} acción(es) NO se pudieron cablear tras los reintentos (revertidas, ver documento): ${result.dropped.map((d) => d.intent).join(', ')}.${reset || ''}`);
1735
+ } else if (result.manualCount) {
1736
+ console.error(`${tag} · ${dim || ''}${result.manualCount} acción(es) quedaron para revisión (ver el documento).${reset || ''}`);
1737
+ }
1738
+ if (result.test && result.test.ok) {
1739
+ console.error(`${tag} · ✓ test post-cableado OK (${result.test.stage}).`);
1740
+ } else if (result.preexistingFailure) {
1741
+ console.error(`${tag} · ${dim || ''}aviso: tu app YA no compilaba ANTES del cableado (${result.test && result.test.stage}); no es por Fiodos, así que conservo el cableado pero no puedo verificarlo por build.${reset || ''}`);
1742
+ } else if (result.status === 'applied-untested') {
1743
+ console.error(`${tag} · ${dim || ''}aviso: no pude verificar el build (${result.test && result.test.stage}); revísalo.${reset || ''}`);
1744
+ }
1745
+ console.error(`${tag} · ${dim || ''}actívalo pasando registries={fyodosGeneratedRegistries} a <FiodosAgent/> (snippet en el documento).${reset || ''}`);
1746
+ break;
1747
+ }
1748
+ case 'reverted':
1749
+ console.error(`${tag} · ✗ el test post-cableado falló (${result.test && result.test.stage}); REVERTÍ todos los cambios para no dejar tu app rota.`);
1750
+ console.error(`${tag} · ${dim || ''}detalle en el documento; nada quedó modificado en tu código.${reset || ''}`);
1751
+ break;
1752
+ case 'manual-only':
1753
+ console.error(`${tag} · ${dim || ''}cableado documentado; aplícalo a mano siguiendo ${result.docPath}.${reset || ''}`);
1754
+ break;
1755
+ case 'declined':
1756
+ case 'declined-noninteractive':
1757
+ // already reported inline
1758
+ break;
1759
+ case 'failed':
1760
+ console.error(`${tag} · ✗ no se pudo escribir el registro de handlers: ${result.error && result.error.message}`);
1761
+ break;
1762
+ case 'no-actions':
1763
+ case 'skipped':
1764
+ default:
1765
+ break;
1766
+ }
1767
+ }
1768
+
1769
+ module.exports = {
1770
+ wireHandlers,
1771
+ reportHandlerResult,
1772
+ // exported for tests
1773
+ prepareHandlerPlan,
1774
+ buildHandlerDoc,
1775
+ buildRegistryModule,
1776
+ buildBridgeModule,
1777
+ planComponentEdits,
1778
+ applyComponentEdits,
1779
+ stripFiodosBlocks,
1780
+ revertAll,
1781
+ runAutoCorrectionLoop,
1782
+ writeIsolated,
1783
+ planOneAction,
1784
+ pickRelevantFiles,
1785
+ parseParamList,
1786
+ findExportedFunction,
1787
+ findStoreMethod,
1788
+ mapParams,
1789
+ };