@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 +2 -2
- package/src/aiAnalyze.js +9 -0
- package/src/index.js +20 -6
- package/src/postWireTest.js +51 -3
- package/src/renderProbe.js +69 -8
- package/src/verifyWire.js +61 -5
- package/src/wireHandlers.js +167 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 }) {
|
package/src/postWireTest.js
CHANGED
|
@@ -24,7 +24,56 @@
|
|
|
24
24
|
|
|
25
25
|
const fs = require('fs');
|
|
26
26
|
const path = require('path');
|
|
27
|
-
const {
|
|
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 =
|
|
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,
|
package/src/renderProbe.js
CHANGED
|
@@ -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,
|
|
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')
|
|
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)
|
|
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.
|
|
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)
|
|
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)
|
|
69
|
-
|
|
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 = {
|
|
268
|
+
module.exports = {
|
|
269
|
+
probeEffect, compileRegressed, extractErrorLines, readDexieStores,
|
|
270
|
+
classifyErrorLines, isRealCompileError, isLintError,
|
|
271
|
+
};
|
package/src/wireHandlers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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
|
-
|
|
1740
|
-
|
|
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;
|