@fiodos/cli 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Fiodos CLI — analyzes your app's source code and generates the in-app voice-agent manifest, then wires the orb. Powers `npx @fiodos/cli analyze`.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "node": ">=18"
20
20
  },
21
21
  "dependencies": {
22
- "@fiodos/core": "^0.1.1",
22
+ "@fiodos/core": "^0.1.2",
23
23
  "esbuild": "^0.28.1",
24
24
  "jsdom": "^24.0.0",
25
25
  "typescript": "^5.6.0"
package/src/aiAnalyze.js CHANGED
@@ -103,6 +103,15 @@ const PLATFORM_GUIDES = {
103
103
  'Web actions come in TWO kinds, BOTH first-class — detect both:\n' +
104
104
  ' (1) FUNCTION actions (kind "function"): functions wired to UI — handlers inside components, store/context actions (Zustand, Redux, React Context), or service functions. Set "handler" to that real function name exactly. INCLUDE common web product actions you might be tempted to skip: switching language/locale (e.g. setLang/setLocale/i18n.changeLanguage), toggling theme, submitting a contact/newsletter/search form. (Still skip pure micro-plumbing like opening a dropdown or controlled input onChange.)\n' +
105
105
  ' (2) LINK actions (kind "link"): the core actions of marketing/landing pages, which are NAVIGATION not function calls — download/get-the-app buttons (App Store / Google Play / direct download), "contact us" / email / phone links, social links, and in-page jumps to a section (anchor hrefs like #pricing). For these set kind "link" and navTarget to the EXACT href string in the code; do NOT set handler. A list of detected link elements on the product-surface pages is provided below — turn the meaningful ones into link actions (group obvious duplicates, e.g. several App Store buttons → one "download on the App Store").\n' +
106
+ 'VITAL WEB SECTIONS — almost every website exposes a small set of high-value, low-risk destinations that users very commonly ask for. Before finishing, actively SCAN the code and the detected-links list for these and INCLUDE the ones that genuinely exist (each as a link action when it is a navigation/href, or a function action when it is a real handler). Do NOT invent any that are not in the code — but do not overlook them either, because these are exactly what users ask the assistant for:\n' +
107
+ ' • Contact / get in touch (mailto:, tel:, a /contact page or #contact anchor, a contact form)\n' +
108
+ ' • Help / support / FAQ / docs (a help center link, support email, /faq or /help)\n' +
109
+ ' • Social media profiles (links to Instagram, X/Twitter, LinkedIn, YouTube, TikTok, GitHub, etc. — one action per network)\n' +
110
+ ' • Download / get the app (store or direct-download links)\n' +
111
+ ' • Change language / locale (a language switcher link or a setLang/i18n handler)\n' +
112
+ ' • Legal (privacy policy, terms of service, cookies)\n' +
113
+ ' • Pricing / plans, and newsletter/subscribe, when present.\n' +
114
+ 'These vital sections apply to EVERY web product, not any specific one — treat them as a standing checklist for all websites.\n' +
106
115
  'CRITICAL: the manifest is for THIS web deployment only — do NOT include routes or actions from mobile/native code that is not in the file list (even if you infer it exists elsewhere in the monorepo).',
107
116
  },
108
117
  mobile: {
package/src/index.js CHANGED
@@ -506,6 +506,11 @@ async function main() {
506
506
  };
507
507
 
508
508
  const runWebWire = async ({ setLabel, pause, resume }) => {
509
+ // Emit a report on its OWN clean line: pause the live spinner (clears its
510
+ // line), print, then resume. Without this, a log written while the spinner
511
+ // is ticking gets glued onto the spinner line ("…10sFiodos · …").
512
+ const report = (fn) => { pause(); fn(); resume(); };
513
+
509
514
  // --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
510
515
  // isolation without modifying the app's entrypoint / package.json).
511
516
  if (!process.argv.includes('--no-orb-wire')) {
@@ -519,13 +524,20 @@ async function main() {
519
524
  runTest: !process.argv.includes('--no-wire-test'),
520
525
  });
521
526
  if (!assumeYes) resume();
522
- reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() });
527
+ report(() => reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() }));
523
528
  if (
524
529
  publishing &&
525
530
  apiKey &&
526
531
  (result.status === 'added' || result.status === 'already')
527
532
  ) {
528
- await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
533
+ // Records the install proof so the dashboard onboarding orb check can
534
+ // complete without booting the app. If the backend can't record it
535
+ // (e.g. an outdated deployment without the endpoint), keep it as a
536
+ // dev-only trace — it is not actionable noise for the end developer.
537
+ const proof = await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
538
+ if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
539
+ logDev(`[orb-wired] install proof not recorded (status=${proof.status || proof.reason}); dashboard check will rely on a runtime ping`);
540
+ }
529
541
  }
530
542
  }
531
543
 
@@ -533,7 +545,7 @@ async function main() {
533
545
  if (!assumeYes) pause();
534
546
  const handlerResult = await wireHandlers(analysisRoot, handlerOpts);
535
547
  if (!assumeYes) resume();
536
- reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
548
+ report(() => reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() }));
537
549
  };
538
550
 
539
551
  if (publishing) {
@@ -846,7 +858,7 @@ async function publishManifest(manifest, meta, analysisToken = null, opts = {})
846
858
  * already succeeded); a failed re-fetch is surfaced as an actionable warning.
847
859
  */
848
860
  async function postOrbWired({ apiKey, apiUrl, file }) {
849
- if (!apiKey) return;
861
+ if (!apiKey) return { ok: false, reason: 'no-api-key' };
850
862
  try {
851
863
  const res = await fetch(`${apiUrl}/v1/developer/orb-wired`, {
852
864
  method: 'POST',
@@ -855,11 +867,13 @@ async function postOrbWired({ apiKey, apiUrl, file }) {
855
867
  });
856
868
  if (res.ok) {
857
869
  logDev(`[orb-wired] install proof recorded (${file || 'web'})`);
858
- } else {
859
- logDev(`[orb-wired] could not record install proof: HTTP ${res.status}`);
870
+ return { ok: true };
860
871
  }
872
+ logDev(`[orb-wired] could not record install proof: HTTP ${res.status}`);
873
+ return { ok: false, status: res.status };
861
874
  } catch (e) {
862
875
  logDev(`[orb-wired] skipped: ${e.message || e}`);
876
+ return { ok: false, reason: e.message || String(e) };
863
877
  }
864
878
  }
865
879
  async function verifyOrbCanFetch({ apiKey, apiUrl }) {
@@ -24,7 +24,56 @@
24
24
 
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
- const { spawnSync } = require('child_process');
27
+ const { spawn } = require('child_process');
28
+
29
+ /**
30
+ * Run a command WITHOUT blocking Node's event loop (unlike spawnSync). This
31
+ * matters for the live terminal spinner: spawnSync freezes the whole process
32
+ * while the build runs, so the elapsed-seconds counter appears to jump in big
33
+ * steps ("stuck"). With async spawn the loop stays free and the counter ticks
34
+ * every second. Resolves to a spawnSync-shaped result { status, stdout, stderr,
35
+ * error } and never rejects.
36
+ */
37
+ function runAsync(cmd, args, opts = {}) {
38
+ return new Promise((resolve) => {
39
+ let child;
40
+ try {
41
+ child = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
42
+ } catch (error) {
43
+ resolve({ status: null, stdout: '', stderr: '', error });
44
+ return;
45
+ }
46
+ let stdout = '';
47
+ let stderr = '';
48
+ const max = opts.maxBuffer || 64 * 1024 * 1024;
49
+ // Bound total captured output (the way maxBuffer would) without throwing.
50
+ child.stdout.on('data', (c) => { if (stdout.length + stderr.length < max) stdout += c.toString(); });
51
+ child.stderr.on('data', (c) => { if (stdout.length + stderr.length < max) stderr += c.toString(); });
52
+
53
+ let timedOut = false;
54
+ let timer = null;
55
+ if (opts.timeout) {
56
+ timer = setTimeout(() => {
57
+ timedOut = true;
58
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
59
+ }, opts.timeout);
60
+ }
61
+ child.on('error', (error) => {
62
+ if (timer) clearTimeout(timer);
63
+ resolve({ status: null, stdout, stderr, error });
64
+ });
65
+ child.on('close', (code) => {
66
+ if (timer) clearTimeout(timer);
67
+ if (timedOut) {
68
+ const error = new Error('ETIMEDOUT');
69
+ error.code = 'ETIMEDOUT';
70
+ resolve({ status: null, stdout, stderr, error });
71
+ return;
72
+ }
73
+ resolve({ status: code, stdout, stderr, error: null });
74
+ });
75
+ });
76
+ }
28
77
 
29
78
  function readPkg(appRoot) {
30
79
  try {
@@ -70,9 +119,8 @@ async function runPostWireTest(appRoot, opts = {}) {
70
119
  return { ok: true, stage: 'skipped-no-deps', command: '(none)', output: 'no build/typecheck script.' };
71
120
  }
72
121
 
73
- const res = spawnSync('npm', chosen.args, {
122
+ const res = await runAsync('npm', chosen.args, {
74
123
  cwd: appRoot,
75
- encoding: 'utf8',
76
124
  timeout: timeoutMs,
77
125
  env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
78
126
  maxBuffer: 64 * 1024 * 1024,
@@ -48,6 +48,46 @@ function isVerboseProbe() {
48
48
  return process.argv.includes('--verbose') || process.argv.includes('-v') || process.argv.includes('--debug');
49
49
  }
50
50
 
51
+ /**
52
+ * Render-probe teardown guard.
53
+ *
54
+ * The probe mounts the user's real React component in jsdom, then unmounts and
55
+ * tears the DOM down. React 18 schedules passive-effect cleanup ASYNCHRONOUSLY
56
+ * (a scheduler timer/microtask). If any cleanup fires AFTER we removed the
57
+ * jsdom globals, React throws "The `document` global was defined when React was
58
+ * initialized, but is not defined anymore". On Node ≥ 21/25 that async throw is
59
+ * an uncaughtException that kills the ENTIRE CLI — long after the probe returned
60
+ * (e.g. while the CLI is awaiting the ".env" prompt). The probe is an OPTIONAL
61
+ * effect check; it must never crash an otherwise-successful install.
62
+ *
63
+ * We install (once) a narrow process-level filter that swallows ONLY this jsdom/
64
+ * React teardown error class and lets every other error crash as usual.
65
+ */
66
+ let teardownGuardInstalled = false;
67
+ function isProbeTeardownError(err) {
68
+ const msg = String((err && (err.stack || err.message)) || err || '');
69
+ return (
70
+ /`document` global was defined when React was initialized/.test(msg) ||
71
+ /\bdocument is not defined\b/.test(msg) ||
72
+ /Cannot read properties of (?:null|undefined) \(reading '(?:document|ownerDocument|defaultView|createElement|body)'\)/.test(msg) ||
73
+ (/jsdom/i.test(msg) && /not defined|closed|null/i.test(msg))
74
+ );
75
+ }
76
+ function installProbeTeardownGuard() {
77
+ if (teardownGuardInstalled) return;
78
+ teardownGuardInstalled = true;
79
+ process.on('uncaughtException', (err) => {
80
+ if (isProbeTeardownError(err)) return; // late probe passive-effect cleanup — safe to drop
81
+ // Not ours: preserve Node's default fail-fast behavior for real errors.
82
+ console.error(err && err.stack ? err.stack : err);
83
+ process.exit(1);
84
+ });
85
+ process.on('unhandledRejection', (reason) => {
86
+ if (isProbeTeardownError(reason)) return;
87
+ throw reason;
88
+ });
89
+ }
90
+
51
91
  /**
52
92
  * Capture (swallow) noisy global console output during a probe — React error
53
93
  * boundary logs ("The above error occurred in <X>", "Consider adding an error
@@ -150,8 +190,18 @@ async function probeReactEffect(appRoot, entry, ctx) {
150
190
  `import { flushSync } from 'react-dom';\n` +
151
191
  `export const probe = { React: ReactNS.default || ReactNS, createRoot, flushSync, Comp, registry: fyodosGeneratedRegistries };\n`;
152
192
 
193
+ // A late React passive-effect cleanup that fires after we tear down jsdom must
194
+ // never crash the CLI (see installProbeTeardownGuard).
195
+ installProbeTeardownGuard();
196
+
153
197
  let restore = () => {};
154
198
  let cap = { restore() {} };
199
+ // Hoisted so the finally block can drain React (synchronously unmount + settle)
200
+ // BEFORE the DOM globals are removed — the only reliable way to stop React from
201
+ // running cleanup against a closed jsdom.
202
+ let root = null;
203
+ let flushSync = null;
204
+ const settle = () => new Promise((r) => setTimeout(r, 0));
155
205
  try {
156
206
  fs.writeFileSync(harnessAbs, harness);
157
207
  await esbuild.build({
@@ -173,14 +223,13 @@ async function probeReactEffect(appRoot, entry, ctx) {
173
223
  restore = installDom(jsdomMod, { storageKeys: ctx.storageKeys, kind: ctx.kind });
174
224
  delete require.cache[outAbs];
175
225
  const mod = require(outAbs);
176
- const { React, createRoot, flushSync, Comp, registry } = mod.probe;
226
+ const { React, createRoot, Comp, registry } = mod.probe;
227
+ flushSync = mod.probe.flushSync;
177
228
  const Component = pickComponent(Comp);
178
229
  if (!Component) return { status: 'unverifiable', detail: `did not find a mountable React component exported by '${entry.bridge.file}' — real effect not verifiable, test by hand` };
179
230
 
180
231
  const container = document.createElement('div');
181
232
  document.body.appendChild(container);
182
- let root;
183
- const settle = () => new Promise((r) => setTimeout(r, 0));
184
233
  try {
185
234
  flushSync(() => { root = createRoot(container); root.render(React.createElement(Component)); });
186
235
  await settle();
@@ -192,12 +241,11 @@ async function probeReactEffect(appRoot, entry, ctx) {
192
241
  const beforeHTML = captureSnapshot(container);
193
242
  const beforeText = container.textContent || '';
194
243
  const handler = registry.handlers[entry.handler];
195
- if (typeof handler !== 'function') { try { root.unmount(); } catch {} return { status: 'fail', detail: `el registro no expone el handler '${entry.handler}'` }; }
244
+ if (typeof handler !== 'function') return { status: 'fail', detail: `el registro no expone el handler '${entry.handler}'` };
196
245
 
197
246
  if (ctx.sensitive) {
198
247
  // Do NOT fire the effect; we proved it mounts and the handler resolves to a
199
248
  // live bridge function. Verify the bridge has the method, nothing more.
200
- try { root.unmount(); } catch {}
201
249
  return { status: 'unverifiable', detail: 'confirmation action: its effect is not fired in the test render (security); wiring verified up to the confirmation point' };
202
250
  }
203
251
 
@@ -205,15 +253,25 @@ async function probeReactEffect(appRoot, entry, ctx) {
205
253
  try { await handler(ctx.params); } catch (e) { invokeErr = e; }
206
254
  await settle();
207
255
  try { flushSync(() => {}); } catch {}
208
- if (invokeErr) { try { root.unmount(); } catch {} return { status: 'fail', detail: `invoking the handler, the real app threw: ${short(invokeErr)}` }; }
256
+ if (invokeErr) return { status: 'fail', detail: `invoking the handler, the real app threw: ${short(invokeErr)}` };
209
257
 
210
258
  const afterHTML = captureSnapshot(container);
211
259
  const afterText = container.textContent || '';
212
- try { root.unmount(); } catch {}
213
260
  return decide(ctx.kind, { beforeHTML, beforeText, afterHTML, afterText });
214
261
  } catch (err) {
215
262
  return { status: 'unverifiable', detail: `could not prepare the test render (${short(err)}); real effect not automatically verifiable — test by hand` };
216
263
  } finally {
264
+ // Drain React WHILE the DOM is still alive: a synchronous unmount runs every
265
+ // pending passive-effect cleanup now, so nothing fires after we remove the
266
+ // jsdom globals below. Two settle ticks flush the scheduler's macro/microtasks.
267
+ try {
268
+ if (root) {
269
+ if (flushSync) flushSync(() => { try { root.unmount(); } catch { /* ignore */ } });
270
+ else { try { root.unmount(); } catch { /* ignore */ } }
271
+ await settle();
272
+ await settle();
273
+ }
274
+ } catch { /* ignore */ }
217
275
  try { cap.restore(); } catch {}
218
276
  try { fs.existsSync(harnessAbs) && fs.rmSync(harnessAbs); } catch {}
219
277
  try { fs.existsSync(outAbs) && fs.rmSync(outAbs); } catch {}
@@ -357,6 +415,9 @@ function installDom(jsdomMod, { storageKeys = [], kind } = {}) {
357
415
  * `sources` are the project's source files (for storage-key detection, etc.).
358
416
  */
359
417
  async function probeComponentEffect(appRoot, entry, opts = {}) {
418
+ // Defense-in-depth: a late headless-render teardown error (React/Vue passive
419
+ // cleanup firing after jsdom is closed) must never crash the CLI.
420
+ installProbeTeardownGuard();
360
421
  const framework = (opts.framework || 'web').toLowerCase();
361
422
  const kind = actionKind(entry);
362
423
  const params = probeParams(entry, kind);
@@ -376,4 +437,4 @@ async function probeComponentEffect(appRoot, entry, opts = {}) {
376
437
  return { status: 'unverifiable', detail: `framework '${framework}' has no render-probe; real effect not automatically verifiable` };
377
438
  }
378
439
 
379
- module.exports = { probeComponentEffect, actionKind, decide, SENTINEL, SEED_ID, installDom, pickComponent, detectStorageKeys, probeParams, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin, captureConsole, isVerboseProbe };
440
+ module.exports = { probeComponentEffect, actionKind, decide, SENTINEL, SEED_ID, installDom, pickComponent, detectStorageKeys, probeParams, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin, captureConsole, isVerboseProbe, installProbeTeardownGuard, isProbeTeardownError };
package/src/verifyWire.js CHANGED
@@ -47,11 +47,57 @@ function normErr(line) {
47
47
  .trim();
48
48
  }
49
49
 
50
+ /**
51
+ * Is this output line a GENUINE compile/type/syntax failure (i.e. the code is
52
+ * really broken), as opposed to a style violation? These mean the safety net
53
+ * MUST act: the action does not actually work.
54
+ */
55
+ function isRealCompileError(line) {
56
+ const l = String(line);
57
+ return /error\s+TS\d+|Type error:|Cannot find (?:module|name)|is not assignable|does not exist on type|has no exported member|Module not found|SyntaxError|Unexpected (?:token|keyword|reserved word)|Expression expected|Parsing error/i.test(l);
58
+ }
59
+
60
+ /**
61
+ * Is this output line a pure ESLint/style complaint? These do NOT mean the code
62
+ * is broken — they mean it breaks the PROJECT's style rules. We neutralize those
63
+ * on our own generated files and fence our injected blocks, so honestly they
64
+ * should never reach here; when one does, we still treat it as non-broken code.
65
+ */
66
+ function isLintError(line) {
67
+ const l = String(line);
68
+ if (isRealCompileError(l)) return false;
69
+ // Reference to a known ESLint rule namespace / a bare `no-*` rule.
70
+ if (/@typescript-eslint\/|react-hooks\/|jsx-a11y\/|@next\/next\/|\bimport\/[a-z-]+|\breact\/[a-z-]+|prettier\/|\bno-[a-z][a-z-]+\b/.test(l)) return true;
71
+ // ESLint compact format "12:5 Error message rule" without a TS error code.
72
+ if (/^\s*\d+:\d+\s+(?:error|warning)\b/i.test(l) && !/TS\d+/.test(l)) return true;
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * Split error lines into { real, lint, unknown }. Unknown lines (an "Error" we
78
+ * cannot confidently classify) are treated as REAL by callers — we fail safe
79
+ * toward "the code might be broken", never toward shipping a red build.
80
+ */
81
+ function classifyErrorLines(lines) {
82
+ const real = [];
83
+ const lint = [];
84
+ const unknown = [];
85
+ for (const l of lines || []) {
86
+ if (isRealCompileError(l)) real.push(l);
87
+ else if (isLintError(l)) lint.push(l);
88
+ else unknown.push(l);
89
+ }
90
+ return { real, lint, unknown };
91
+ }
92
+
50
93
  /**
51
94
  * Did applying THIS action introduce a NEW build problem? Compare position-
52
95
  * normalized error lines after vs. baseline. Any error that is new (not present
53
96
  * in baseline) AND references a file we touched or our generated modules is
54
- * attributed to this action. Returns { ok, newErrors: string[] }.
97
+ * attributed to this action.
98
+ * Returns { ok, newErrors, realErrors, lintErrors }: `ok` is false on a genuine
99
+ * regression; `realErrors`/`lintErrors` classify the attributed lines so callers
100
+ * can tell "code is broken" (revert) from "style rule tripped" (style only).
55
101
  */
56
102
  function compileRegressed(baselineOutput, afterOutput, touchedFiles) {
57
103
  const before = new Set(extractErrorLines(baselineOutput).map(normErr));
@@ -61,12 +107,19 @@ function compileRegressed(baselineOutput, afterOutput, touchedFiles) {
61
107
  const ours = fresh.filter(
62
108
  (l) => /handlers\.generated|fyodos[\\/]bridge|FYODOS/i.test(l) || touched.some((t) => t && l.includes(t)),
63
109
  );
64
- if (ours.length) return { ok: false, newErrors: ours };
110
+ if (ours.length) {
111
+ const { real, lint, unknown } = classifyErrorLines(ours);
112
+ return { ok: false, newErrors: ours, realErrors: [...real, ...unknown], lintErrors: lint };
113
+ }
65
114
  // Only when the app built cleanly before (no baseline errors) do we treat any
66
115
  // unattributable fresh error as a regression. If it was already broken, an
67
116
  // unattributable error is almost certainly pre-existing → don't blame ourselves.
68
- if (fresh.length && !before.size) return { ok: false, newErrors: fresh.slice(0, 8) };
69
- return { ok: true, newErrors: [] };
117
+ if (fresh.length && !before.size) {
118
+ const slice = fresh.slice(0, 8);
119
+ const { real, lint, unknown } = classifyErrorLines(slice);
120
+ return { ok: false, newErrors: slice, realErrors: [...real, ...unknown], lintErrors: lint };
121
+ }
122
+ return { ok: true, newErrors: [], realErrors: [], lintErrors: [] };
70
123
  }
71
124
 
72
125
  // ── Effect probe (in-process, real instance) ─────────────────────────────────────
@@ -212,4 +265,7 @@ async function probeEffect(appRoot, entry, opts = {}) {
212
265
  return { status: 'skipped', detail: 'module target not safely simulable in-process; verified by compilation' };
213
266
  }
214
267
 
215
- module.exports = { probeEffect, compileRegressed, extractErrorLines, readDexieStores };
268
+ module.exports = {
269
+ probeEffect, compileRegressed, extractErrorLines, readDexieStores,
270
+ classifyErrorLines, isRealCompileError, isLintError,
271
+ };
@@ -40,7 +40,7 @@
40
40
  const fs = require('fs');
41
41
  const path = require('path');
42
42
  const readline = require('readline');
43
- const { probeEffect, compileRegressed } = require('./verifyWire');
43
+ const { probeEffect, compileRegressed, classifyErrorLines } = require('./verifyWire');
44
44
 
45
45
  const DOC_BASENAME = 'FYODOS_HANDLERS.md';
46
46
  const REGISTRY_BASENAME = 'handlers.generated';
@@ -52,6 +52,31 @@ const GENERATED_MARKER = 'GENERATED by Fiodos — handler wiring';
52
52
  const EDIT_START = '// FYODOS:BRIDGE:START (auto-generado por Fiodos — seguro de eliminar)';
53
53
  const EDIT_END = '// FYODOS:BRIDGE:END';
54
54
 
55
+ // Directives that make OUR generated files (handlers.generated, bridge) invisible
56
+ // to the PROJECT's strict lint/typecheck. These files are ours, not the
57
+ // developer's source, so they must not be judged by the project's STYLE rules: a
58
+ // single `no-explicit-any` / `no-unused-vars` from ESLint, or an `exactOptional`
59
+ // nit from a strict tsconfig, would otherwise fail `next build` and force a full
60
+ // revert even though the code runs fine. We neutralize STYLE, never behavior —
61
+ // if the REAL build (after this) still fails, that is a genuine error and the
62
+ // safety net still reverts. `@ts-nocheck` MUST be in the leading comments.
63
+ // ORDER MATTERS: `/* eslint-disable */` MUST be the very first line so it also
64
+ // covers the `@ts-nocheck` line below — otherwise `@typescript-eslint/ban-ts-comment`
65
+ // fires on `@ts-nocheck` itself (it is linted before a later disable takes effect).
66
+ // TypeScript still honors `@ts-nocheck` when it sits in the leading comments, even
67
+ // after another comment, so this ordering keeps BOTH the linter and tsc quiet.
68
+ const GENERATED_FILE_DIRECTIVES =
69
+ '/* eslint-disable */\n' +
70
+ '// @ts-nocheck\n' +
71
+ '// biome-ignore-all\n';
72
+ // Same idea for the few lines we inject INTO the user's own files: we cannot
73
+ // disable type-checking on their file, so the injected code is written clean (no
74
+ // explicit `any`, no unused params — see planComponentEdits), and we additionally
75
+ // fence it from ESLint with a scoped disable/enable pair so no project style rule
76
+ // can turn our insertion into a build-breaking error.
77
+ const INJECT_ESLINT_DISABLE = '/* eslint-disable */';
78
+ const INJECT_ESLINT_ENABLE = '/* eslint-enable */';
79
+
55
80
  function esc(name) {
56
81
  return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
82
  }
@@ -767,6 +792,7 @@ function buildRegistryModule(plan, opts = {}) {
767
792
  : `const ${REGISTRY_EXPORT}${ts ? ': FiodosRegistries' : ''} = {`;
768
793
 
769
794
  const body =
795
+ `${GENERATED_FILE_DIRECTIVES}` +
770
796
  `${header}\n` +
771
797
  `${importLines.join('\n')}\n\n` +
772
798
  (typeDefs ? `${typeDefs}\n` : '') +
@@ -794,6 +820,7 @@ function buildBridgeModule(opts = {}) {
794
820
  const ts = opts.ts !== false;
795
821
  const esm = opts.esm !== false;
796
822
  const header =
823
+ `${GENERATED_FILE_DIRECTIVES}` +
797
824
  `/**\n` +
798
825
  ` * ${GENERATED_MARKER} (bridge holder).\n` +
799
826
  ` * Do not edit by hand. Your component registers its real functions here and the\n` +
@@ -870,11 +897,19 @@ function planComponentEdits(appRoot, plan, opts = {}) {
870
897
  }
871
898
  }
872
899
 
873
- const argType = ts ? ': any' : '';
874
900
  const classField = entries.some((e) => e.bridge.scope === 'class-field');
875
901
  const methods = entries.map((e) => {
876
902
  const inv = e.bridge.invoke;
877
- return ` ${JSON.stringify(e.bridge.method)}: (args${argType}) => (${inv}),`;
903
+ // This code lands in the USER's file, so it cannot carry `@ts-nocheck`; it
904
+ // must be lint- and type-clean by construction:
905
+ // · NO explicit `any` annotation — the param type comes contextually from
906
+ // registerFiodosBridge's signature, so there is no `no-explicit-any` and
907
+ // no implicit-any either.
908
+ // · OMIT the param entirely when the invocation never reads it, so strict
909
+ // `no-unused-vars` cannot fire (TS allows callbacks with fewer params).
910
+ const usesArgs = /\bargs\b/.test(inv);
911
+ const param = usesArgs ? 'args' : '';
912
+ return ` ${JSON.stringify(e.bridge.method)}: (${param}) => (${inv}),`;
878
913
  });
879
914
 
880
915
  // Imports: registerFiodosBridge + any extra the invokes need (deduped).
@@ -897,7 +932,10 @@ function planComponentEdits(appRoot, plan, opts = {}) {
897
932
  const registerCall = classField
898
933
  ? `private __fyodosBridge = registerFiodosBridge({\n${methods.join('\n')}\n});`
899
934
  : `registerFiodosBridge({\n${methods.join('\n')}\n});`;
900
- const registerBlock = `${EDIT_START}\n${registerCall}\n${EDIT_END}`;
935
+ // Fence our insertion from the project's ESLint config (style rules only — the
936
+ // code itself is clean and type-checked). `@ts-nocheck` can't be scoped to a
937
+ // block inside the user's file, so type-correctness is guaranteed by codegen.
938
+ const registerBlock = `${EDIT_START}\n${INJECT_ESLINT_DISABLE}\n${registerCall}\n${INJECT_ESLINT_ENABLE}\n${EDIT_END}`;
901
939
 
902
940
  // Match the anchor's leading indentation so the inserted block stays tidy.
903
941
  const lineStart = content.lastIndexOf('\n', bestIdx) + 1;
@@ -907,7 +945,7 @@ function planComponentEdits(appRoot, plan, opts = {}) {
907
945
  file,
908
946
  anchor,
909
947
  scope: classField ? 'class-field' : 'statement',
910
- importBlock: `${EDIT_START}\n${importLines.join('\n')}\n${EDIT_END}`,
948
+ importBlock: `${EDIT_START}\n${INJECT_ESLINT_DISABLE}\n${importLines.join('\n')}\n${INJECT_ESLINT_ENABLE}\n${EDIT_END}`,
911
949
  registerBlock: registerBlock.split('\n').map((l) => (l ? indent + l : l)).join('\n'),
912
950
  intents: entries.map((e) => e.intent),
913
951
  });
@@ -1248,6 +1286,35 @@ function applyComponentEdits(appRoot, edits) {
1248
1286
  return { backups, edited };
1249
1287
  }
1250
1288
 
1289
+ /**
1290
+ * Map regression error lines back to the specific entries that caused them, so
1291
+ * we can revert ONLY the offending actions instead of the whole batch:
1292
+ * · BRIDGE entries → the error references the user file we edited (by basename).
1293
+ * · MODULE entries → the error sits in handlers.generated and mentions the
1294
+ * intent / handler / call expression of that entry.
1295
+ * Returns a Set of culprit intents. Empty means "could not isolate" (the caller
1296
+ * then falls back to a full revert rather than guessing).
1297
+ */
1298
+ function attributeCulprits(entries, errorLines) {
1299
+ const culprits = new Set();
1300
+ const lines = (errorLines || []).map(String);
1301
+ if (!lines.length) return culprits;
1302
+ for (const e of entries || []) {
1303
+ const intent = e.intent;
1304
+ if (e.kind === 'bridge') {
1305
+ const base = path.basename(normRel(e.bridge && e.bridge.file));
1306
+ if (base && lines.some((l) => l.includes(base))) culprits.add(intent);
1307
+ } else {
1308
+ const tokens = [intent, e.handler, e.callExpr].filter(Boolean).map(String);
1309
+ const inRegistry = lines.some(
1310
+ (l) => /handlers\.generated/i.test(l) && tokens.some((t) => t && l.includes(t)),
1311
+ );
1312
+ if (inRegistry) culprits.add(intent);
1313
+ }
1314
+ }
1315
+ return culprits;
1316
+ }
1317
+
1251
1318
  /** Restore files to their pre-edit content and delete generated files. */
1252
1319
  function revertAll(backups, generatedFiles) {
1253
1320
  for (const b of backups || []) {
@@ -1398,7 +1465,13 @@ async function runAutoCorrectionLoop(ctx) {
1398
1465
  try {
1399
1466
  const after = await testRunner(appRoot, { framework });
1400
1467
  const reg = compileRegressed(baseline.output, after.output, iso.touched);
1401
- if (!reg.ok) { ok = false; lastError = `compilation: ${reg.newErrors.join(' | ').slice(0, 600)}`; }
1468
+ // Only a GENUINE compile/type error means the action is broken. A pure
1469
+ // ESLint/style complaint is neutralized in what we actually write (our
1470
+ // files carry @ts-nocheck + eslint-disable; injected blocks are fenced),
1471
+ // so it must not drop the action nor burn an AI-correction attempt.
1472
+ if (!reg.ok && reg.realErrors && reg.realErrors.length) {
1473
+ ok = false; lastError = `compilation: ${reg.realErrors.join(' | ').slice(0, 600)}`;
1474
+ }
1402
1475
  if (ok) {
1403
1476
  const eff = await probeEffect(appRoot, entry, { fyodosDirRel, framework, sources: files, sensitive: entry.requireConfirmation });
1404
1477
  if (eff.status === 'fail') { ok = false; stage = 'effect'; lastError = `effect: ${eff.detail}`; }
@@ -1638,16 +1711,83 @@ async function wireHandlers(appRoot, opts = {}) {
1638
1711
  }
1639
1712
 
1640
1713
  // Final combined build (each action passed in isolation; confirm the union).
1641
- let test;
1642
- try {
1643
- test = await testRunner(appRoot, { framework });
1644
- } catch (err) {
1645
- test = { ok: false, stage: 'runner', output: err && err.message };
1646
- }
1647
- const reg = compileRegressed((baseline && baseline.output) || '', (test && test.output) || '',
1648
- [...finalEdits.map((e) => e.file), 'handlers.generated', 'bridge']);
1649
- const combinedRegression = !reg.ok && baseline && baseline.ok;
1714
+ const touchedForReg = () => [...finalEdits.map((e) => e.file), 'handlers.generated', 'bridge'];
1715
+ const runBuild = async () => {
1716
+ try {
1717
+ return await testRunner(appRoot, { framework });
1718
+ } catch (err) {
1719
+ return { ok: false, stage: 'runner', output: err && err.message };
1720
+ }
1721
+ };
1722
+ let test = await runBuild();
1723
+ let reg = compileRegressed((baseline && baseline.output) || '', (test && test.output) || '', touchedForReg());
1724
+ let combinedRegression = !reg.ok && baseline && baseline.ok;
1725
+
1726
+ // PARTIAL DEGRADATION: if the union regressed, don't nuke the whole batch.
1727
+ // Attribute the NEW errors to the specific entries that caused them, revert
1728
+ // only those, and rebuild once with the survivors. The dev loses the
1729
+ // problematic action(s), never the rest. We attribute on the full error set
1730
+ // (real + lint): a non-zero build is unacceptable to ship regardless of the
1731
+ // cause, but we record WHY each culprit fell (broken code vs. a style rule
1732
+ // our fence could not silence) for an honest report.
1733
+ let degraded = null;
1650
1734
  if (combinedRegression && revertOnFailure) {
1735
+ const culprits = attributeCulprits(finalAuto, reg.newErrors);
1736
+ const survivors = finalAuto.filter((e) => !culprits.has(e.intent));
1737
+ if (culprits.size && survivors.length && survivors.length < finalAuto.length) {
1738
+ const { real, lint } = classifyErrorLines(reg.newErrors);
1739
+ const culpritEntries = finalAuto.filter((e) => culprits.has(e.intent));
1740
+ // Revert everything, then re-apply ONLY the survivors and rebuild once.
1741
+ revertAll(backups, generatedFiles);
1742
+ const survivorPlan = { auto: survivors, review: droppedReview, manual: droppedReview, entries: [...survivors, ...droppedReview] };
1743
+ const survivorEdits = planComponentEdits(appRoot, survivorPlan, { ts });
1744
+ const survivorHasBridge = survivors.some((e) => e.kind === 'bridge');
1745
+ const survivorGenerated = [registryAbs];
1746
+ let survivorBackups = [];
1747
+ try {
1748
+ if (survivorHasBridge) {
1749
+ fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
1750
+ survivorGenerated.push(bridgeAbs);
1751
+ }
1752
+ fs.writeFileSync(registryAbs, buildRegistryModule(survivorPlan, { ts, esm }));
1753
+ ({ backups: survivorBackups } = applyComponentEdits(appRoot, survivorEdits));
1754
+ } catch (err) {
1755
+ revertAll(survivorBackups, survivorGenerated);
1756
+ return { status: 'failed', docPath: docAbs, error: err, plan: finalPlan, verification: loop.attemptsLog };
1757
+ }
1758
+ const survivorTest = await runBuild();
1759
+ const survivorReg = compileRegressed((baseline && baseline.output) || '', (survivorTest && survivorTest.output) || '', [...survivorEdits.map((e) => e.file), 'handlers.generated', 'bridge']);
1760
+ if (survivorReg.ok || !(baseline && baseline.ok)) {
1761
+ // Survivors build clean → keep them; report the dropped culprits.
1762
+ degraded = culpritEntries.map((e) => ({
1763
+ ...e, confidence: 'review',
1764
+ reason: `wired clean in isolation but broke the combined build${lint.length && !real.length ? ' (project style rule our fence could not silence)' : ''}; reverted to keep the rest building`,
1765
+ }));
1766
+ const mergedReview = [...droppedReview, ...degraded];
1767
+ const keptPlan = { auto: survivors, review: mergedReview, manual: mergedReview, entries: [...survivors, ...mergedReview] };
1768
+ fs.writeFileSync(docAbs, buildHandlerDoc(keptPlan, {
1769
+ appName: appName || path.basename(appRoot),
1770
+ framework, registryRel, bridgeRel, edits: survivorEdits, mountSnippet,
1771
+ verification: loop.attemptsLog,
1772
+ }));
1773
+ return {
1774
+ status: 'applied', partial: true, docPath: docAbs, registryFile: registryAbs, registryRel,
1775
+ bridgeFile: survivorHasBridge ? bridgeAbs : null, mountSnippet,
1776
+ autoCount: survivors.length, manualCount: mergedReview.length,
1777
+ editedFiles: survivorEdits.map((e) => e.file), plan: keptPlan,
1778
+ verification: loop.attemptsLog, dropped: [...loop.dropped, ...degraded],
1779
+ degraded, baseline, test: survivorTest,
1780
+ preexistingFailure: baseline && baseline.ok === false && baseline.stage !== 'skipped-no-deps',
1781
+ };
1782
+ }
1783
+ // Even the survivors regressed → fall through to a full revert (already reverted).
1784
+ revertAll(survivorBackups, survivorGenerated);
1785
+ return {
1786
+ status: 'reverted', docPath: docAbs, plan: finalPlan, test: survivorTest,
1787
+ verification: loop.attemptsLog, dropped: loop.dropped, baseline,
1788
+ };
1789
+ }
1790
+ // Could not isolate a culprit → conservative full revert.
1651
1791
  revertAll(backups, generatedFiles);
1652
1792
  return {
1653
1793
  status: 'reverted', docPath: docAbs, plan: finalPlan, test,
@@ -1733,11 +1873,21 @@ function reportHandlerResult(result, colors = {}, opts = {}) {
1733
1873
  switch (result.status) {
1734
1874
  case 'applied':
1735
1875
  case 'applied-untested':
1876
+ if (result.partial && Array.isArray(result.degraded) && result.degraded.length) {
1877
+ // Partial degradation: most actions are wired; only the few that broke the
1878
+ // combined build were rolled back so the rest (and the build) stay green.
1879
+ const names = result.degraded.map((d) => `'${d.intent}'`).join(', ');
1880
+ console.error(`${tag} · ${dim || ''}Wired ${result.autoCount} action(s). ${result.degraded.length} (${names}) wired clean alone but broke the combined build, so only those were reverted — the rest are live. See the document to apply them by hand.${reset || ''}`);
1881
+ return;
1882
+ }
1736
1883
  if (quiet) return;
1737
1884
  break;
1738
1885
  case 'reverted':
1739
- console.error(`${tag} · Post-wiring build check failed (${result.test && result.test.stage}) reverted all changes, your app is untouched.`);
1740
- console.error(`${tag} · ${dim || ''}Details in the document; nothing was modified in your code.${reset || ''}`);
1886
+ // Not a fatal error: the manifest is published and the orb + link actions
1887
+ // work. Only the OPTIONAL in-code wiring for function actions was rolled
1888
+ // back because the project's build did not pass with it — so we leave the
1889
+ // code untouched and document how to wire it by hand. Keep the tone calm.
1890
+ console.error(`${tag} · ${dim || ''}Voice actions are published and the orb is live. The optional in-code wiring for function actions was skipped (your build didn't pass with it), so your code was left untouched — see the document to apply it by hand.${reset || ''}`);
1741
1891
  break;
1742
1892
  case 'manual-only':
1743
1893
  if (quiet) return;