@fiodos/cli 0.1.12 → 0.1.14
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 +1 -1
- package/src/aiAnalyze.js +9 -0
- package/src/index.js +28 -16
- package/src/postWireTest.js +51 -3
- 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.14",
|
|
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": {
|
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
|
@@ -157,6 +157,19 @@ function logUser(line) {
|
|
|
157
157
|
console.log(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/** Spinner copy: "website" for web targets, "app" for mobile/native. */
|
|
161
|
+
function surfaceNoun(platform) {
|
|
162
|
+
return platform === 'web' ? 'website' : 'app';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function orbMountSpinnerLabel(platform) {
|
|
166
|
+
return `Mounting the orb on your ${surfaceNoun(platform)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function wiringSpinnerLabel(platform) {
|
|
170
|
+
return `Wiring actions to your ${surfaceNoun(platform)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
/** Branded terminal spinner (Fiodos orb + colors when stderr is a TTY). */
|
|
161
174
|
async function withSpinner(label, work) {
|
|
162
175
|
const orbFrames = ['◴', '◷', '◶', '◵'];
|
|
@@ -506,10 +519,15 @@ async function main() {
|
|
|
506
519
|
};
|
|
507
520
|
|
|
508
521
|
const runWebWire = async ({ setLabel, pause, resume }) => {
|
|
522
|
+
// Emit a report on its OWN clean line: pause the live spinner (clears its
|
|
523
|
+
// line), print, then resume. Without this, a log written while the spinner
|
|
524
|
+
// is ticking gets glued onto the spinner line ("…10sFiodos · …").
|
|
525
|
+
const report = (fn) => { pause(); fn(); resume(); };
|
|
526
|
+
|
|
509
527
|
// --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
|
|
510
528
|
// isolation without modifying the app's entrypoint / package.json).
|
|
511
529
|
if (!process.argv.includes('--no-orb-wire')) {
|
|
512
|
-
setLabel(
|
|
530
|
+
setLabel(orbMountSpinnerLabel(platform));
|
|
513
531
|
if (!assumeYes) pause();
|
|
514
532
|
const result = await wireWebOrb(analysisRoot, {
|
|
515
533
|
apiUrl,
|
|
@@ -519,34 +537,28 @@ async function main() {
|
|
|
519
537
|
runTest: !process.argv.includes('--no-wire-test'),
|
|
520
538
|
});
|
|
521
539
|
if (!assumeYes) resume();
|
|
522
|
-
reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() });
|
|
540
|
+
report(() => reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
523
541
|
if (
|
|
524
542
|
publishing &&
|
|
525
543
|
apiKey &&
|
|
526
544
|
(result.status === 'added' || result.status === 'already')
|
|
527
545
|
) {
|
|
546
|
+
// Records the install proof so the dashboard onboarding orb check can
|
|
547
|
+
// complete without booting the app. If the backend can't record it
|
|
548
|
+
// (e.g. an outdated deployment without the endpoint), keep it as a
|
|
549
|
+
// dev-only trace — it is not actionable noise for the end developer.
|
|
528
550
|
const proof = await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
|
|
529
|
-
// The dashboard's onboarding orb check completes on THIS install proof
|
|
530
|
-
// (no need to run the app). If the backend could not record it (e.g. an
|
|
531
|
-
// outdated deployment without the orb-wired endpoint), say so plainly so
|
|
532
|
-
// the developer knows the check will otherwise wait for a runtime ping.
|
|
533
551
|
if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
|
|
534
|
-
|
|
535
|
-
? 'the backend does not expose /v1/developer/orb-wired yet (update the backend deployment)'
|
|
536
|
-
: `the backend rejected the install proof (HTTP ${proof.status || '?'})`;
|
|
537
|
-
logUser(
|
|
538
|
-
`Note: the orb is wired into your app, but ${why}. ` +
|
|
539
|
-
'The dashboard install check will complete once the orb connects from your running app.',
|
|
540
|
-
);
|
|
552
|
+
logDev(`[orb-wired] install proof not recorded (status=${proof.status || proof.reason}); dashboard check will rely on a runtime ping`);
|
|
541
553
|
}
|
|
542
554
|
}
|
|
543
555
|
}
|
|
544
556
|
|
|
545
|
-
setLabel(
|
|
557
|
+
setLabel(wiringSpinnerLabel(platform));
|
|
546
558
|
if (!assumeYes) pause();
|
|
547
559
|
const handlerResult = await wireHandlers(analysisRoot, handlerOpts);
|
|
548
560
|
if (!assumeYes) resume();
|
|
549
|
-
reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
|
|
561
|
+
report(() => reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
550
562
|
};
|
|
551
563
|
|
|
552
564
|
if (publishing) {
|
|
@@ -557,7 +569,7 @@ async function main() {
|
|
|
557
569
|
});
|
|
558
570
|
logUser('Published to your project');
|
|
559
571
|
} else {
|
|
560
|
-
await withSpinner(
|
|
572
|
+
await withSpinner(orbMountSpinnerLabel(platform), runWebWire);
|
|
561
573
|
}
|
|
562
574
|
|
|
563
575
|
// ── 8. API key in environment (LAST step — always asks; independent of --yes)
|
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/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;
|