@fiodos/cli 0.1.28 → 0.1.30
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 +40 -0
- package/src/collect.js +75 -8
- package/src/verify.js +94 -1
- package/src/verifyWire.js +8 -1
- package/src/wireHandlers.js +99 -3
- package/src/wireWeb.js +2 -2
- package/src/wireWebMount.js +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
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
|
@@ -326,6 +326,34 @@ async function analyzeViaProxy({ apiKey, apiUrl, model, user, task = 'analysis',
|
|
|
326
326
|
'Or pass --own-ai-key to analyze with your own AI key (code never reaches Fiodos).',
|
|
327
327
|
);
|
|
328
328
|
}
|
|
329
|
+
// PRE-FLIGHT platform check (analysis only, non-web platforms): the deployed
|
|
330
|
+
// backend may be OLDER than this CLI and not know the platform yet — its
|
|
331
|
+
// prompt builder then silently falls back to the generic web prompt and the
|
|
332
|
+
// resulting manifest misses the platform spec entirely (e.g. Shopify's
|
|
333
|
+
// execution blocks). The system-prompt endpoint echoes the platform it
|
|
334
|
+
// resolved, so a stale backend is detectable BEFORE spending any AI tokens.
|
|
335
|
+
// Fail-open on network/endpoint errors: the post-call echo guard still runs.
|
|
336
|
+
if (task === 'analysis' && platform && platform !== 'web') {
|
|
337
|
+
try {
|
|
338
|
+
const probe = await fetch(
|
|
339
|
+
`${apiUrl}/v1/developer/analysis/system-prompt?task=analysis&platform=${encodeURIComponent(platform)}`,
|
|
340
|
+
{ headers: { 'x-api-key': apiKey } },
|
|
341
|
+
);
|
|
342
|
+
if (probe.ok) {
|
|
343
|
+
const meta = await probe.json();
|
|
344
|
+
if (meta && meta.platform && meta.platform !== platform) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`The Fiodos backend at ${apiUrl} does not support --platform ${platform} yet ` +
|
|
347
|
+
`(it would analyze as "${meta.platform}", missing the ${platform} platform spec). ` +
|
|
348
|
+
'Update/redeploy the backend and re-run the analysis.',
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch (e) {
|
|
353
|
+
if (String(e && e.message).includes('does not support --platform')) throw e;
|
|
354
|
+
/* preflight is best-effort — the post-call echo guard still protects */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
329
357
|
const res = await fetch(`${apiUrl}/v1/developer/analyze`, {
|
|
330
358
|
method: 'POST',
|
|
331
359
|
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
|
@@ -351,6 +379,18 @@ async function analyzeViaProxy({ apiKey, apiUrl, model, user, task = 'analysis',
|
|
|
351
379
|
throw new Error(`Hosted analysis failed: ${res.status} ${detail}`);
|
|
352
380
|
}
|
|
353
381
|
const data = await res.json();
|
|
382
|
+
// Platform degradation guard: an outdated backend that does not know the
|
|
383
|
+
// requested platform silently builds the generic WEB prompt instead — the
|
|
384
|
+
// manifest then LOOKS fine but misses the platform's spec (e.g. Shopify's
|
|
385
|
+
// declarative execution blocks, without which no commerce action can run).
|
|
386
|
+
// New backends echo the platform they actually used; a mismatch is fatal.
|
|
387
|
+
if (data.platform && platform && data.platform !== platform) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`The Fiodos backend at ${apiUrl} does not support --platform ${platform} yet ` +
|
|
390
|
+
`(it analyzed as "${data.platform}"). The manifest it would produce is missing ` +
|
|
391
|
+
`the ${platform} platform spec. Update/redeploy the backend and re-run the analysis.`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
354
394
|
return {
|
|
355
395
|
parsed: parseJsonContent(data.text || '{}'),
|
|
356
396
|
usage: {
|
package/src/collect.js
CHANGED
|
@@ -80,14 +80,27 @@ function tierFor(rel, platform) {
|
|
|
80
80
|
if (/^sections\//.test(p)) return 2;
|
|
81
81
|
return 3; // snippets/, config/ (settings_data.json can be huge), rest
|
|
82
82
|
}
|
|
83
|
+
return tierForApp(p);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function tierForApp(p) {
|
|
83
87
|
if (/^(src\/)?app\//.test(p)) return 0; // expo-router / Next screens
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
// `sections/` and `panels/` are SCREEN BODIES in shell-based apps (a Shell
|
|
89
|
+
// page switches between section components): they carry the visible controls
|
|
90
|
+
// (color pickers, toggles) the analyzer must read to emit their actions.
|
|
91
|
+
// Ranking them as generic components (tier 3) silently dropped them under
|
|
92
|
+
// budget pressure and whole screens became invisible to the AI.
|
|
93
|
+
if (/(^|\/)(screens?|pages?|views?|routes?|sections?|panels?|activities|fragments)\//i.test(p)) return 0;
|
|
94
|
+
if (/(screen|page|view|activity|fragment|section)\.(dart|kt|swift)$/i.test(p)) return 0;
|
|
95
|
+
// Central state gets its OWN tier right after screens: the providers/stores
|
|
96
|
+
// define the setters that make screen controls wire-able actions. Sharing a
|
|
97
|
+
// tier with the (numerous) service/lib files let alphabetical fill starve
|
|
98
|
+
// out AppProvider-style files, and the AI had to guess every handler name.
|
|
86
99
|
if (/(^|\/)(state|store|stores|context|contexts|redux|providers?|blocs?|cubits?|viewmodels?)\//i.test(p)) return 1;
|
|
87
100
|
if (/(viewmodel|notifier|bloc|cubit|store|controller)\.(dart|kt|swift)$/i.test(p)) return 1;
|
|
88
|
-
if (/(^|\/)(services?|api|db|data|domain|agent|handlers?|actions?|lib|utils|repositories|repository|usecases?)\//i.test(p)) return
|
|
89
|
-
if (/(^|\/)(hooks)\//.test(p)) return
|
|
90
|
-
return
|
|
101
|
+
if (/(^|\/)(services?|api|db|data|domain|agent|handlers?|actions?|lib|utils|repositories|repository|usecases?)\//i.test(p)) return 2;
|
|
102
|
+
if (/(^|\/)(hooks)\//.test(p)) return 3;
|
|
103
|
+
return 4; // generic components / ui
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' } = {}) {
|
|
@@ -108,7 +121,21 @@ function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' }
|
|
|
108
121
|
if (!codeExt.has(path.extname(entry.name))) continue;
|
|
109
122
|
if (SKIP_FILES.test(rel)) continue;
|
|
110
123
|
if (isNative && SKIP_NATIVE_FILES.test(rel)) continue;
|
|
111
|
-
|
|
124
|
+
let content = fs.readFileSync(full, 'utf8');
|
|
125
|
+
if (platform === 'shopify' && /^locales\//.test(rel.replace(/\\/g, '/'))) {
|
|
126
|
+
// Theme-editor translations (merchant-facing settings strings), not
|
|
127
|
+
// storefront language evidence — pure noise for the analysis.
|
|
128
|
+
if (/\.schema\.json$/.test(rel)) continue;
|
|
129
|
+
// Non-default storefront locales: what matters is THAT the language is
|
|
130
|
+
// published (the filename carries the code) — that is the evidence the
|
|
131
|
+
// MULTILINGUAL rule needs to emit appLanguages/bubblePrompts. Sending
|
|
132
|
+
// each file whole (tens of KB × dozens of locales) blew the budget and
|
|
133
|
+
// the alphabetical cut dropped exactly the important ones (en/es
|
|
134
|
+
// arrived after bg/cs/da…), so the AI saw a "monolingual" store.
|
|
135
|
+
if (!/\.default\.json$/.test(rel)) {
|
|
136
|
+
content = '{"__fyodos_note": "published storefront locale (content elided; this language IS offered to shoppers — count it in appLanguages/bubblePrompts)"}';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
112
139
|
files.push({ rel, content, tier: tierFor(rel, platform), chars: content.length });
|
|
113
140
|
}
|
|
114
141
|
};
|
|
@@ -118,10 +145,48 @@ function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' }
|
|
|
118
145
|
files.sort((a, b) => a.tier - b.tier || a.rel.localeCompare(b.rel));
|
|
119
146
|
|
|
120
147
|
const budgetChars = maxInputTokens * CHARS_PER_TOKEN;
|
|
148
|
+
|
|
149
|
+
// Floor-reserved fill. A pure greedy fill let a fat tier 0 (many large
|
|
150
|
+
// screen files) consume the WHOLE budget and push out tier 1 entirely —
|
|
151
|
+
// i.e. the app's central state/providers (AppProvider, stores) vanished
|
|
152
|
+
// from the analysis, so the AI could not see the setters that make
|
|
153
|
+
// configuration controls real actions and had to guess every handler name.
|
|
154
|
+
// Each lower tier gets a guaranteed FLOOR (share of the total budget,
|
|
155
|
+
// capped at its real demand); screens still get everything left over, and a
|
|
156
|
+
// final backfill hands unused room to deferred files in tier order.
|
|
157
|
+
const FLOOR_SHARE = { 1: 0.2, 2: 0.05, 3: 0.02, 4: 0.02 };
|
|
158
|
+
const demand = new Map();
|
|
159
|
+
for (const f of files) demand.set(f.tier, (demand.get(f.tier) ?? 0) + f.chars);
|
|
160
|
+
|
|
121
161
|
const included = [];
|
|
122
|
-
const
|
|
162
|
+
const deferred = [];
|
|
123
163
|
let used = 0;
|
|
124
|
-
|
|
164
|
+
const tiers = [...demand.keys()].sort((a, b) => a - b);
|
|
165
|
+
for (const tier of tiers) {
|
|
166
|
+
// Reserve, for every LOWER-priority tier that still has files, the smaller
|
|
167
|
+
// of its floor and what it actually needs.
|
|
168
|
+
let reserved = 0;
|
|
169
|
+
for (const t of tiers) {
|
|
170
|
+
if (t <= tier) continue;
|
|
171
|
+
reserved += Math.min((FLOOR_SHARE[t] ?? 0) * budgetChars, demand.get(t));
|
|
172
|
+
}
|
|
173
|
+
const available = budgetChars - used - reserved;
|
|
174
|
+
let tierUsed = 0;
|
|
175
|
+
for (const f of files) {
|
|
176
|
+
if (f.tier !== tier) continue;
|
|
177
|
+
if (tierUsed + f.chars <= available) {
|
|
178
|
+
included.push(f);
|
|
179
|
+
tierUsed += f.chars;
|
|
180
|
+
} else {
|
|
181
|
+
deferred.push(f);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
used += tierUsed;
|
|
185
|
+
}
|
|
186
|
+
// Backfill: any room the floors did not consume goes back to the deferred
|
|
187
|
+
// files, highest tier priority first.
|
|
188
|
+
const omitted = [];
|
|
189
|
+
for (const f of deferred) {
|
|
125
190
|
if (used + f.chars <= budgetChars) {
|
|
126
191
|
included.push(f);
|
|
127
192
|
used += f.chars;
|
|
@@ -129,6 +194,8 @@ function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' }
|
|
|
129
194
|
omitted.push({ rel: f.rel, chars: f.chars, tier: f.tier });
|
|
130
195
|
}
|
|
131
196
|
}
|
|
197
|
+
// Restore deterministic tier/path order after the backfill.
|
|
198
|
+
included.sort((a, b) => a.tier - b.tier || a.rel.localeCompare(b.rel));
|
|
132
199
|
|
|
133
200
|
return {
|
|
134
201
|
included,
|
package/src/verify.js
CHANGED
|
@@ -122,6 +122,79 @@ function sanitizeBubblePrompt(raw) {
|
|
|
122
122
|
return s || undefined;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** "es-ES" / "ES" → "es" (base language code, lowercased); undefined when unusable. */
|
|
126
|
+
function sanitizeLanguageCode(raw) {
|
|
127
|
+
const code = String(raw || '').split(/[-_]/)[0].trim().toLowerCase();
|
|
128
|
+
return /^[a-z]{2,3}$/.test(code) ? code : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sanitize a route's `bubblePrompts` translation map (fail-open, item never
|
|
133
|
+
* dropped): keeps only valid base-language keys whose values survive the same
|
|
134
|
+
* bubblePrompt hygiene. Returns undefined when nothing usable remains, so the
|
|
135
|
+
* SDK falls back to the single `bubblePrompt`.
|
|
136
|
+
*/
|
|
137
|
+
function sanitizeBubblePrompts(raw) {
|
|
138
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
141
|
+
const lang = sanitizeLanguageCode(key);
|
|
142
|
+
const prompt = sanitizeBubblePrompt(value);
|
|
143
|
+
if (lang && prompt && !(lang in out)) out[lang] = prompt;
|
|
144
|
+
}
|
|
145
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Max length (chars) of a dashboard `labels` translation — it renders as a
|
|
150
|
+
* short UI label (route/action name), never a sentence.
|
|
151
|
+
*/
|
|
152
|
+
const MAX_LABEL = 60;
|
|
153
|
+
|
|
154
|
+
/** Sanitize one dashboard label translation string (see sanitizeBubblePrompt). */
|
|
155
|
+
function sanitizeLabelText(raw) {
|
|
156
|
+
if (typeof raw !== 'string') return undefined;
|
|
157
|
+
let s = raw.replace(/\s+/g, ' ').trim();
|
|
158
|
+
s = s.replace(/^["'«»“”]+|["'«»“”]+$/g, '').trim();
|
|
159
|
+
if (!s) return undefined;
|
|
160
|
+
if (s.length > MAX_LABEL) s = s.slice(0, MAX_LABEL).trim();
|
|
161
|
+
return s || undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Sanitize a route/action's `labels` translation map (fail-open, item never
|
|
166
|
+
* dropped): keeps only valid base-language keys whose values survive label
|
|
167
|
+
* hygiene. Returns undefined when nothing usable remains, so the dashboard
|
|
168
|
+
* falls back to the primary `label`. Unlike `bubblePrompts`, this map is
|
|
169
|
+
* REQUIRED by the analyzer prompt (es/en, unconditional) — but the CLI still
|
|
170
|
+
* never blocks a manifest over a missing/malformed translation.
|
|
171
|
+
*/
|
|
172
|
+
function sanitizeLabels(raw) {
|
|
173
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
176
|
+
const lang = sanitizeLanguageCode(key);
|
|
177
|
+
const label = sanitizeLabelText(value);
|
|
178
|
+
if (lang && label && !(lang in out)) out[lang] = label;
|
|
179
|
+
}
|
|
180
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sanitize the manifest-level `appLanguages` list (fail-open): dedupes and
|
|
185
|
+
* normalizes to base codes, preserving the model's order (primary first).
|
|
186
|
+
* Returns undefined when nothing usable remains (monolingual apps).
|
|
187
|
+
*/
|
|
188
|
+
function sanitizeAppLanguages(raw) {
|
|
189
|
+
if (!Array.isArray(raw)) return undefined;
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const entry of raw) {
|
|
192
|
+
const lang = sanitizeLanguageCode(entry);
|
|
193
|
+
if (lang && !out.includes(lang)) out.push(lang);
|
|
194
|
+
}
|
|
195
|
+
return out.length > 0 ? out : undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
125
198
|
/** Normalize an expo-router href for comparison: strip (groups), trailing /index. */
|
|
126
199
|
function normalizeHref(href) {
|
|
127
200
|
return ('/' + String(href || '')
|
|
@@ -399,6 +472,15 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
399
472
|
}
|
|
400
473
|
if (bubble) route.bubblePrompt = bubble;
|
|
401
474
|
else delete route.bubblePrompt;
|
|
475
|
+
// Multilingual bubble translations (fail-open): keep only clean entries so
|
|
476
|
+
// the orb can follow the app's active language; drop the map when empty.
|
|
477
|
+
const bubbles = sanitizeBubblePrompts(route.bubblePrompts);
|
|
478
|
+
if (bubbles) route.bubblePrompts = bubbles;
|
|
479
|
+
else delete route.bubblePrompts;
|
|
480
|
+
// Dashboard label translations (es/en) — see sanitizeLabels doc.
|
|
481
|
+
const routeLabels = sanitizeLabels(route.labels);
|
|
482
|
+
if (routeLabels) route.labels = routeLabels;
|
|
483
|
+
else delete route.labels;
|
|
402
484
|
}
|
|
403
485
|
|
|
404
486
|
// Schema coercion the model sometimes needs (same as the old engine).
|
|
@@ -414,10 +496,21 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
414
496
|
action.confirmationMessageTemplate = `Confirm: ${action.label || action.intent}?`;
|
|
415
497
|
provenance.notes.push(`action '${action.intent}': confirmation template filled mechanically`);
|
|
416
498
|
}
|
|
499
|
+
// Dashboard label translations (es/en) — see sanitizeLabels doc.
|
|
500
|
+
const actionLabels = sanitizeLabels(action.labels);
|
|
501
|
+
if (actionLabels) action.labels = actionLabels;
|
|
502
|
+
else delete action.labels;
|
|
417
503
|
}
|
|
418
504
|
|
|
505
|
+
// App-level supported languages (fail-open): normalized base codes, primary
|
|
506
|
+
// first; dropped entirely when the analysis found no i18n evidence.
|
|
507
|
+
const appLanguages = sanitizeAppLanguages(manifest.appLanguages);
|
|
508
|
+
const cleanManifest = { ...manifest, routes: dedupedRoutes, actions: dedupedActions };
|
|
509
|
+
if (appLanguages) cleanManifest.appLanguages = appLanguages;
|
|
510
|
+
else delete cleanManifest.appLanguages;
|
|
511
|
+
|
|
419
512
|
return {
|
|
420
|
-
manifest:
|
|
513
|
+
manifest: cleanManifest,
|
|
421
514
|
provenance,
|
|
422
515
|
};
|
|
423
516
|
}
|
package/src/verifyWire.js
CHANGED
|
@@ -29,7 +29,14 @@ function normRel(p) {
|
|
|
29
29
|
/** Extract "path(line,col): error" / "ERROR ... path:line" style lines. */
|
|
30
30
|
function extractErrorLines(output) {
|
|
31
31
|
const lines = String(output || '').split('\n');
|
|
32
|
-
return lines.filter((l) =>
|
|
32
|
+
return lines.filter((l) => {
|
|
33
|
+
// npm's failure banner ("npm error path …", "npm ERR! command failed") is
|
|
34
|
+
// the WRAPPER around a failed script, not a diagnostic. Counting it as an
|
|
35
|
+
// error made an action fail with "compilation: npm error path <dir>" even
|
|
36
|
+
// when every real tsc line had been classified as style-only.
|
|
37
|
+
if (/^\s*npm\s+(error|ERR!)/i.test(l)) return false;
|
|
38
|
+
return /error\s+TS\d+|: error|ERROR\b|is not assignable|Cannot find|does not exist/i.test(l);
|
|
39
|
+
});
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
/**
|
package/src/wireHandlers.js
CHANGED
|
@@ -576,6 +576,36 @@ function deriveBridgeAnchor(content, invoke) {
|
|
|
576
576
|
return null;
|
|
577
577
|
}
|
|
578
578
|
|
|
579
|
+
/**
|
|
580
|
+
* "Once wired, never regressed": when the file ALREADY contains a generated
|
|
581
|
+
* block registering this intent (from a previous verified run), reuse that
|
|
582
|
+
* position instead of re-rolling the AI's anchor. Re-running the wiring is
|
|
583
|
+
* otherwise nondeterministic — a bad anchor + an unlucky AI correction can
|
|
584
|
+
* un-wire an action that worked yesterday. Returns the nearest line ABOVE the
|
|
585
|
+
* existing block that survives the strip and is unique in the stripped
|
|
586
|
+
* content (a deterministic anchor for the same spot), or null.
|
|
587
|
+
*/
|
|
588
|
+
function reuseExistingBlockAnchor(rawContent, strippedContent, intent) {
|
|
589
|
+
const blockRe = new RegExp(
|
|
590
|
+
`(${esc(EDIT_START_PREFIX)}[^\\n]*)\\n[\\s\\S]*?${esc(EDIT_END)}`,
|
|
591
|
+
'g',
|
|
592
|
+
);
|
|
593
|
+
let m;
|
|
594
|
+
while ((m = blockRe.exec(rawContent))) {
|
|
595
|
+
if (!GENERATED_HEADER_RE.test(m[1])) continue;
|
|
596
|
+
if (!new RegExp(`"${esc(intent)}"\\s*:`).test(m[0])) continue;
|
|
597
|
+
const lines = rawContent.slice(0, m.index).split('\n');
|
|
598
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
599
|
+
const line = lines[i];
|
|
600
|
+
const t = line.trim();
|
|
601
|
+
if (!t || t.startsWith('//') || t.startsWith('/*') || t.startsWith('*')) continue;
|
|
602
|
+
if (line.includes('FYODOS:')) continue;
|
|
603
|
+
if (countOccurrences(strippedContent, line) === 1) return line;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
579
609
|
/**
|
|
580
610
|
* True when an anchor that appears several times is HARMLESS: every occurrence
|
|
581
611
|
* resolves to the same actual insertion point (the nearest JSX `return`), so
|
|
@@ -634,7 +664,13 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
|
|
|
634
664
|
const occ = anchor ? countOccurrences(content, anchor) : 0;
|
|
635
665
|
if (occ !== 1) {
|
|
636
666
|
const scope = b.scope === 'class-field' ? 'class-field' : 'statement';
|
|
637
|
-
|
|
667
|
+
const reused = reuseExistingBlockAnchor(rawContent, content, base.intent);
|
|
668
|
+
if (reused) {
|
|
669
|
+
// A previous run already wired this intent here and verified it; the
|
|
670
|
+
// proven position beats re-rolling an ambiguous AI anchor.
|
|
671
|
+
anchor = reused;
|
|
672
|
+
anchorDerived = true;
|
|
673
|
+
} else if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
|
|
638
674
|
// Keep the AI anchor: every occurrence lands on the same insertion point.
|
|
639
675
|
} else {
|
|
640
676
|
const derived = deriveBridgeAnchor(content, invoke);
|
|
@@ -675,6 +711,13 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
|
|
|
675
711
|
const rawFrom = normRel(imp && imp.from);
|
|
676
712
|
const kind = (imp && imp.kind) || 'named';
|
|
677
713
|
if (!name || !rawFrom) return { reason: 'a bridge import is incomplete (missing name/from)' };
|
|
714
|
+
// The component often ALREADY imports the symbol (useApp, a store hook…).
|
|
715
|
+
// Re-importing it produces "Duplicate identifier" and fails the candidate,
|
|
716
|
+
// so an already-in-scope name is accepted without adding a second import.
|
|
717
|
+
if (new RegExp(`^\\s*import\\b[^;\\n]*[^\\w$]${esc(name)}[^\\w$][^;\\n]*from`, 'm').test(content)) {
|
|
718
|
+
importedNames.add(name);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
678
721
|
const from = resolveSourceFile(appRoot, rawFrom);
|
|
679
722
|
if (!from) return { reason: `the bridge import points to a non-existent file: '${rawFrom}'` };
|
|
680
723
|
if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) return { reason: `named import '${name}' does not exist in '${from}'` };
|
|
@@ -1291,9 +1334,14 @@ function stripFiodosBlocks(content) {
|
|
|
1291
1334
|
);
|
|
1292
1335
|
}
|
|
1293
1336
|
|
|
1294
|
-
/** Insert an import block right after the file's last top-level import
|
|
1337
|
+
/** Insert an import block right after the file's last top-level import statement.
|
|
1338
|
+
* Matches the WHOLE statement through its module string — a per-line regex put
|
|
1339
|
+
* the block INSIDE multi-line imports (`import {\n a,\n} from 'x';`), corrupting
|
|
1340
|
+
* the file (TS1003 "Identifier expected") and failing the wiring of every action
|
|
1341
|
+
* in that file. `[^'"]*` cannot skip a quote, so the match always stops at the
|
|
1342
|
+
* first quoted module specifier, then runs to that line's end. */
|
|
1295
1343
|
function insertImportBlock(content, importBlock) {
|
|
1296
|
-
const importLineRe = /^[ \t]*import\b[^\n]*\n/gm;
|
|
1344
|
+
const importLineRe = /^[ \t]*import\b[^'"]*['"][^'"]+['"][^\n]*\n/gm;
|
|
1297
1345
|
let lastEnd = -1;
|
|
1298
1346
|
let m;
|
|
1299
1347
|
while ((m = importLineRe.exec(content))) lastEnd = m.index + m[0].length;
|
|
@@ -1351,6 +1399,26 @@ function insertRegisterBlock(content, file, anchor, block, scope) {
|
|
|
1351
1399
|
return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
|
|
1352
1400
|
}
|
|
1353
1401
|
|
|
1402
|
+
// When the anchor's own line is unmistakably a body STATEMENT (a declaration
|
|
1403
|
+
// or a bare call ending in `;`), insert right AFTER it. Snapping to the
|
|
1404
|
+
// nearest `return` instead can land the registration inside a conditional
|
|
1405
|
+
// early-return branch (`if (authLoading) { … return <Spinner/> }`): it
|
|
1406
|
+
// compiles, but in normal use that branch never runs and the bridge is never
|
|
1407
|
+
// registered — the action silently dies at runtime.
|
|
1408
|
+
const anchorLineStart = content.lastIndexOf('\n', at) + 1;
|
|
1409
|
+
const anchorLineEnd = content.indexOf('\n', at);
|
|
1410
|
+
const anchorLine = content.slice(anchorLineStart, anchorLineEnd === -1 ? content.length : anchorLineEnd).trim();
|
|
1411
|
+
const isStatementLine =
|
|
1412
|
+
/^(const|let|var)\b.*;\s*$/.test(anchorLine) || /^[\w$][\w$.]*\((?:[^()]|\([^()]*\))*\);\s*$/.test(anchorLine);
|
|
1413
|
+
if (isStatementLine) {
|
|
1414
|
+
// A multi-line declaration (`const { a, b } =\n useApp();`) ends at the
|
|
1415
|
+
// first `;` at/after the anchor, not at the anchor's own line end.
|
|
1416
|
+
const semi = content.indexOf(';', at);
|
|
1417
|
+
const stmtEnd = semi === -1 ? (anchorLineEnd === -1 ? content.length : anchorLineEnd) : content.indexOf('\n', semi);
|
|
1418
|
+
const endIdx = stmtEnd === -1 ? content.length : stmtEnd;
|
|
1419
|
+
return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1354
1422
|
const ret = nearestJsxReturn(content, at);
|
|
1355
1423
|
if (ret !== -1) {
|
|
1356
1424
|
const lineStart = content.lastIndexOf('\n', ret) + 1;
|
|
@@ -1500,6 +1568,34 @@ function pickRelevantFiles(files, evidence, candidate, intent) {
|
|
|
1500
1568
|
for (const imp of (candidate.bridge && candidate.bridge.imports) || []) if (imp && imp.from) wanted.add(normRel(imp.from));
|
|
1501
1569
|
for (const r of candidate.requiredSymbols || []) if (r && r.file) wanted.add(normRel(r.file));
|
|
1502
1570
|
}
|
|
1571
|
+
// The most common unfixable failure is "symbol X is not available in the
|
|
1572
|
+
// proposed file": the fix lives in ANOTHER component that consumes X. Give
|
|
1573
|
+
// the corrector every file that mentions the invoked symbols (capped) so it
|
|
1574
|
+
// can retarget the bridge instead of blindly retrying the same file.
|
|
1575
|
+
const invokeSrc = String(
|
|
1576
|
+
(candidate && ((candidate.bridge && candidate.bridge.invoke) || candidate.call)) || '',
|
|
1577
|
+
);
|
|
1578
|
+
const symbols = new Set();
|
|
1579
|
+
let sm;
|
|
1580
|
+
const symRe = /(?:^|[^.\w$])([A-Za-z_$][\w$]{2,})\s*\(/g;
|
|
1581
|
+
while ((sm = symRe.exec(invokeSrc))) {
|
|
1582
|
+
if (!JS_KEYWORDS.has(sm[1]) && !SAFE_GLOBALS.has(sm[1])) symbols.add(sm[1]);
|
|
1583
|
+
}
|
|
1584
|
+
if (symbols.size) {
|
|
1585
|
+
let extra = 0;
|
|
1586
|
+
for (const f of files) {
|
|
1587
|
+
if (extra >= 4) break;
|
|
1588
|
+
if (wanted.has(normRel(f.rel))) continue;
|
|
1589
|
+
const content = String(f.content || '');
|
|
1590
|
+
for (const sym of symbols) {
|
|
1591
|
+
if (new RegExp(`(^|[^\\w$])${esc(sym)}([^\\w$]|$)`).test(content)) {
|
|
1592
|
+
wanted.add(normRel(f.rel));
|
|
1593
|
+
extra += 1;
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1503
1599
|
const picked = files.filter((f) => wanted.has(normRel(f.rel)));
|
|
1504
1600
|
return picked.length ? picked : files.slice(0, 4);
|
|
1505
1601
|
}
|
package/src/wireWeb.js
CHANGED
|
@@ -134,7 +134,7 @@ function agentSnippetVue() {
|
|
|
134
134
|
`</script>\n\n` +
|
|
135
135
|
`<template>\n` +
|
|
136
136
|
` <!-- ...your app... -->\n` +
|
|
137
|
-
` <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" />\n` +
|
|
137
|
+
` <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" locale-detection="document" />\n` +
|
|
138
138
|
`</template>`
|
|
139
139
|
);
|
|
140
140
|
}
|
|
@@ -144,7 +144,7 @@ function agentSnippetSvelte() {
|
|
|
144
144
|
`<script lang="ts">\n` +
|
|
145
145
|
` import FiodosAgent from '@fiodos/svelte/FiodosAgent.svelte';\n` +
|
|
146
146
|
`</script>\n\n` +
|
|
147
|
-
`<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} />`
|
|
147
|
+
`<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} localeDetection="document" />`
|
|
148
148
|
);
|
|
149
149
|
}
|
|
150
150
|
|
package/src/wireWebMount.js
CHANGED
|
@@ -122,6 +122,10 @@ function buildEntryBootstrapBlock(opts = {}) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
if (screenImportPath) lines.push(` getCurrentRoute: ${SCREEN_ROUTE_EXPORT},`);
|
|
125
|
+
// Language link (never left disconnected): the orb follows the page's live
|
|
126
|
+
// <html lang>, so analyzer bubble translations track the app's language
|
|
127
|
+
// switcher automatically.
|
|
128
|
+
lines.push(" localeDetection: 'document',");
|
|
125
129
|
lines.push(' mount: true,');
|
|
126
130
|
lines.push('});');
|
|
127
131
|
lines.push(ORB_SCRIPT_END);
|
|
@@ -243,6 +247,10 @@ function agentJsx(framework, ts, indent = ' ') {
|
|
|
243
247
|
`${indent}<FiodosAgent\n` +
|
|
244
248
|
`${indent} apiKey={${keyExpr}}\n` +
|
|
245
249
|
`${indent} apiUrl={${urlExpr}}\n` +
|
|
250
|
+
// Language link (never left disconnected): the orb follows the page's live
|
|
251
|
+
// <html lang>, so analyzer bubble translations track the app's language
|
|
252
|
+
// switcher automatically.
|
|
253
|
+
`${indent} localeDetection="document"\n` +
|
|
246
254
|
`${indent}/>`
|
|
247
255
|
);
|
|
248
256
|
}
|
|
@@ -644,7 +652,7 @@ function injectVueSfc(plan) {
|
|
|
644
652
|
}
|
|
645
653
|
const block = [
|
|
646
654
|
ORB_HTML_START,
|
|
647
|
-
' <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" />',
|
|
655
|
+
' <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" locale-detection="document" />',
|
|
648
656
|
ORB_HTML_END,
|
|
649
657
|
].join('\n');
|
|
650
658
|
source = source.replace('</template>', `${block}\n</template>`);
|
|
@@ -662,7 +670,7 @@ function injectSvelteSfc(plan) {
|
|
|
662
670
|
}
|
|
663
671
|
const block = [
|
|
664
672
|
ORB_HTML_START,
|
|
665
|
-
'<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} />',
|
|
673
|
+
'<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} localeDetection="document" />',
|
|
666
674
|
ORB_HTML_END,
|
|
667
675
|
].join('\n');
|
|
668
676
|
if (source.includes('</main>')) {
|
|
@@ -699,7 +707,7 @@ function injectAngularComponent(plan) {
|
|
|
699
707
|
);
|
|
700
708
|
}
|
|
701
709
|
|
|
702
|
-
const block = [ORB_HTML_START, '<fyodos-agent [apiKey]="fyodosApiKey" [baseUrl]="fyodosApiUrl" />', ORB_HTML_END].join('\n');
|
|
710
|
+
const block = [ORB_HTML_START, '<fyodos-agent [apiKey]="fyodosApiKey" [baseUrl]="fyodosApiUrl" localeDetection="document" />', ORB_HTML_END].join('\n');
|
|
703
711
|
html = `${html.trimEnd()}\n${block}\n`;
|
|
704
712
|
writeFile(target.file, ts);
|
|
705
713
|
writeFile(templatePath, html);
|