@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.
- package/LICENSE +77 -0
- package/README.md +128 -0
- package/package.json +30 -0
- package/src/aiAnalyze.js +469 -0
- package/src/ast.js +263 -0
- package/src/collect.js +115 -0
- package/src/graph.js +160 -0
- package/src/index.js +667 -0
- package/src/llm.js +144 -0
- package/src/loadEnv.js +81 -0
- package/src/patterns.js +28 -0
- package/src/postWireTest.js +91 -0
- package/src/renderProbe.js +333 -0
- package/src/renderProbeVue.js +136 -0
- package/src/routes.js +81 -0
- package/src/verify.js +172 -0
- package/src/verifyWire.js +215 -0
- package/src/wireHandlers.js +1789 -0
- package/src/wireWeb.js +295 -0
- package/src/wireWebMount.js +435 -0
|
@@ -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
|
+
};
|