@fiodos/cli 0.1.26 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.29",
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/index.js CHANGED
@@ -175,6 +175,11 @@ function logUser(line) {
175
175
  console.log(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
176
176
  }
177
177
 
178
+ /** Human-readable pointer to the per-run change registry written in the repo. */
179
+ function changeLogUserMessage(paths) {
180
+ return `We saved a summary of every file we changed — see ${paths.mdRel} (past runs in .fyodos/changes/)`;
181
+ }
182
+
178
183
  /**
179
184
  * Ask the developer to confirm before the analysis runs. `yes` continues; any
180
185
  * other answer (or `no`) cancels instantly — the command ends without analyzing.
@@ -925,7 +930,7 @@ async function main() {
925
930
  envResult: envWriteResult,
926
931
  });
927
932
  const paths = writeChangeRegistry(analysisRoot, record);
928
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
933
+ logUser(changeLogUserMessage(paths));
929
934
  } catch (e) {
930
935
  logDev(`[change-registry] could not write: ${e.message}`);
931
936
  }
@@ -973,7 +978,36 @@ async function main() {
973
978
  });
974
979
  logUser('Published to your project');
975
980
  if (orbWireResult && (orbWireResult.status === 'added' || orbWireResult.status === 'already')) {
976
- logUser('Commit & push this theme repo — Shopify\u2019s GitHub sync deploys the orb to your live store.');
981
+ logUser('Commit & push this theme repo.');
982
+ // Shopify's GitHub sync only auto-updates the storefront when the
983
+ // CONNECTED theme is already the LIVE one. A brand-new theme connected
984
+ // from GitHub stays a draft until published ONCE by hand — every push
985
+ // after that keeps it live automatically. Without this line the orb can
986
+ // be fully wired and pushed and still never appear to real customers,
987
+ // with nothing in the CLI output explaining why.
988
+ logUser(
989
+ 'If this is already your LIVE theme, that push alone puts the orb on your store. ' +
990
+ 'If it\u2019s still a DRAFT (check the Shopify admin theme badge), publish it ONCE — ' +
991
+ 'Online Store \u2192 Themes \u2192 \u22ef \u2192 Publish \u2014 then every future push stays live automatically.',
992
+ );
993
+ // New/trial Shopify stores are password-protected by default, and a
994
+ // password-protected storefront renders layout/password.liquid INSTEAD
995
+ // of layout/theme.liquid on every page — so without also wiring that
996
+ // layout (done above when the theme has one) the orb would be
997
+ // correctly installed and published yet invisible behind the password
998
+ // gate. Surfacing this explicitly since it is the #1 "installed
999
+ // correctly but nothing shows up" report on a fresh store.
1000
+ if (orbWireResult.password) {
1001
+ logUser(
1002
+ orbWireResult.password.status === 'failed'
1003
+ ? `Heads up: your store looks password-protected but the orb could not be added to ${orbWireResult.password.file} (${orbWireResult.password.reason}) — it will be invisible until you remove the password or fix that layout.`
1004
+ : 'Your theme has a password page (layout/password.liquid) — the orb is embedded there too, so it still shows up while your store is password-protected.',
1005
+ );
1006
+ } else {
1007
+ logUser(
1008
+ 'Heads up: if your store is still password-protected (common on new/trial stores) and your theme has NO separate layout/password.liquid, the orb won\u2019t show on the password page — remove the password (Online Store \u2192 Preferences) to test, or check under a real domain/plan.',
1009
+ );
1010
+ }
977
1011
  }
978
1012
 
979
1013
  if (!noWire) {
@@ -985,7 +1019,7 @@ async function main() {
985
1019
  orbResult: orbWireResult,
986
1020
  });
987
1021
  const paths = writeChangeRegistry(analysisRoot, record);
988
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
1022
+ logUser(changeLogUserMessage(paths));
989
1023
  } catch (e) {
990
1024
  logDev(`[change-registry] could not write: ${e.message}`);
991
1025
  }
@@ -1108,7 +1142,7 @@ async function main() {
1108
1142
  envResult: envWriteResult,
1109
1143
  });
1110
1144
  const paths = writeChangeRegistry(analysisRoot, record);
1111
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
1145
+ logUser(changeLogUserMessage(paths));
1112
1146
  } catch (e) {
1113
1147
  logDev(`[change-registry] could not write: ${e.message}`);
1114
1148
  }
@@ -1187,7 +1221,7 @@ async function main() {
1187
1221
  envResult: envWriteResult,
1188
1222
  });
1189
1223
  const paths = writeChangeRegistry(analysisRoot, record);
1190
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
1224
+ logUser(changeLogUserMessage(paths));
1191
1225
  } catch (e) {
1192
1226
  logDev(`[change-registry] could not write: ${e.message}`);
1193
1227
  }
@@ -127,6 +127,13 @@ function summarizeBuildOutput(raw, maxLines = 4) {
127
127
  return picked.slice(0, maxLines).join('\n').trim();
128
128
  }
129
129
 
130
+ /** Does this output contain a signal that the CODE is broken (vs npm/env noise)? */
131
+ function looksLikeCompileFailure(output) {
132
+ return /error\s+TS\d+|Type error:|Module not found|Can't resolve|Failed to compile|SyntaxError|Parsing error|Cannot find (?:module|name)/i.test(
133
+ String(output || ''),
134
+ );
135
+ }
136
+
130
137
  async function runPostWireTest(appRoot, opts = {}) {
131
138
  const timeoutMs = opts.timeoutMs || 240000;
132
139
  if (!fs.existsSync(path.join(appRoot, 'node_modules'))) {
@@ -143,12 +150,26 @@ async function runPostWireTest(appRoot, opts = {}) {
143
150
  return { ok: true, stage: 'skipped-no-deps', command: '(none)', output: 'no build/typecheck script.' };
144
151
  }
145
152
 
146
- const res = await runAsync('npm', chosen.args, {
147
- cwd: appRoot,
148
- timeout: timeoutMs,
149
- env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
150
- maxBuffer: 64 * 1024 * 1024,
151
- });
153
+ const runOnce = () =>
154
+ runAsync('npm', chosen.args, {
155
+ cwd: appRoot,
156
+ timeout: timeoutMs,
157
+ env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
158
+ maxBuffer: 64 * 1024 * 1024,
159
+ });
160
+
161
+ let res = await runOnce();
162
+ // A spawn-level or npm-level failure with ZERO compile diagnostics is
163
+ // environment noise (npm cache contention, a concurrent process touching
164
+ // package.json, transient ENOENT), not broken code. Blaming the action for
165
+ // it drops perfectly good wiring, so retry once before judging.
166
+ const npmLevelFailure =
167
+ (res.error && res.error.code !== 'ETIMEDOUT') ||
168
+ (res.status !== 0 && res.status !== null && !looksLikeCompileFailure(`${res.stdout}\n${res.stderr}`));
169
+ if (npmLevelFailure) {
170
+ await new Promise((r) => setTimeout(r, 1500));
171
+ res = await runOnce();
172
+ }
152
173
 
153
174
  if (res.error && res.error.code === 'ETIMEDOUT') {
154
175
  return { ok: false, stage: 'timeout', command: chosen.label, output: `build exceeded ${Math.round(timeoutMs / 1000)}s` };
@@ -240,19 +240,16 @@ function escapeRe(s) {
240
240
  }
241
241
 
242
242
  /**
243
- * Inject (or refresh) the snippet in layout/theme.liquid, right before
243
+ * Inject (or refresh) the snippet into ONE liquid layout file, right before
244
244
  * </body>. Idempotent via the FYODOS:ORB markers: an existing block is
245
245
  * replaced in place, so re-running the CLI updates the snippet instead of
246
- * duplicating it. Returns { status: 'added'|'already'|'skipped'|'failed',
247
- * file, reason? }.
246
+ * duplicating it. Returns { status: 'added'|'already'|'skipped'|'failed'|
247
+ * 'not-found', file, reason?, refreshed? }.
248
248
  */
249
- function wireShopifyTheme(appRoot, { apiKey, apiUrl, noWire = false } = {}) {
250
- const relFile = path.join('layout', 'theme.liquid');
251
- const file = path.join(appRoot, relFile);
249
+ function injectSnippetInLayout(file, relFile, snippet, { noWire = false } = {}) {
252
250
  if (!fs.existsSync(file)) {
253
- return { status: 'failed', file: relFile, reason: 'layout/theme.liquid not found' };
251
+ return { status: 'not-found', file: relFile };
254
252
  }
255
- const snippet = buildShopifySnippet({ apiKey, apiUrl });
256
253
  const source = fs.readFileSync(file, 'utf8');
257
254
 
258
255
  const markerRe = new RegExp(`${escapeRe(ORB_START)}[\\s\\S]*?${escapeRe(ORB_END)}`);
@@ -273,6 +270,41 @@ function wireShopifyTheme(appRoot, { apiKey, apiUrl, noWire = false } = {}) {
273
270
  return { status: 'added', file: relFile };
274
271
  }
275
272
 
273
+ /**
274
+ * Wire the theme's layout(s). layout/theme.liquid is the one every storefront
275
+ * page uses and is REQUIRED. layout/password.liquid — when the theme ships
276
+ * one (Dawn, Horizon and most modern themes do) — is a SEPARATE layout Shopify
277
+ * renders INSTEAD of theme.liquid on every page while the store has password
278
+ * protection active (the default on every new/trial store, and very common on
279
+ * dev stores used to test an install). Without wiring it too, the orb is
280
+ * correctly embedded and published yet invisible to anyone hitting the
281
+ * password gate — "installed correctly but nothing shows up". Best-effort:
282
+ * its absence is normal (older/simpler themes have none) and never fails the
283
+ * install.
284
+ *
285
+ * Returns { status, file, reason?, refreshed?, password: {status, file,...} }
286
+ * — `password` mirrors the same shape for layout/password.liquid, or null
287
+ * when that file does not exist in this theme.
288
+ */
289
+ function wireShopifyTheme(appRoot, { apiKey, apiUrl, noWire = false } = {}) {
290
+ const snippet = buildShopifySnippet({ apiKey, apiUrl });
291
+
292
+ const themeRelFile = path.join('layout', 'theme.liquid');
293
+ const themeFile = path.join(appRoot, themeRelFile);
294
+ if (!fs.existsSync(themeFile)) {
295
+ return { status: 'failed', file: themeRelFile, reason: 'layout/theme.liquid not found', password: null };
296
+ }
297
+ const themeResult = injectSnippetInLayout(themeFile, themeRelFile, snippet, { noWire });
298
+
299
+ const passwordRelFile = path.join('layout', 'password.liquid');
300
+ const passwordFile = path.join(appRoot, passwordRelFile);
301
+ const passwordResult = fs.existsSync(passwordFile)
302
+ ? injectSnippetInLayout(passwordFile, passwordRelFile, snippet, { noWire })
303
+ : null;
304
+
305
+ return { ...themeResult, password: passwordResult };
306
+ }
307
+
276
308
  function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
277
309
  const { blue = '', cyan = '', reset = '' } = colors;
278
310
  const say = (line) => console.error(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
@@ -285,6 +317,13 @@ function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
285
317
  } else if (!quiet) {
286
318
  say(`orb embed skipped (${result.reason || result.status})`);
287
319
  }
320
+
321
+ const pw = result.password;
322
+ if (pw && (pw.status === 'added' || pw.status === 'already')) {
323
+ say(`orb also embedded in ${pw.file} — keeps working while your store is password-protected`);
324
+ } else if (pw && pw.status === 'failed') {
325
+ say(`could not embed the orb in ${pw.file}: ${pw.reason} — the orb won't show while your store is password-protected`);
326
+ }
288
327
  }
289
328
 
290
329
  module.exports = {
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: { ...manifest, routes: dedupedRoutes, actions: dedupedActions },
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) => /error\s+TS\d+|: error|ERROR\b|is not assignable|Cannot find|does not exist/i.test(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
  /**
@@ -52,8 +59,16 @@ function normErr(line) {
52
59
  * really broken), as opposed to a style violation? These mean the safety net
53
60
  * MUST act: the action does not actually work.
54
61
  */
62
+ // "Declared but never used" (TS6133/6192/6196/6198/6199/6205) is a STRICTNESS
63
+ // gate (noUnusedLocals), never broken code. It also appears as an ARTIFACT of
64
+ // per-action isolated verification: wiring one bridge action alone leaves the
65
+ // sibling symbols of the same destructuring temporarily unused, which used to
66
+ // fail every action in that file (the navigate_section/logout production bug).
67
+ const UNUSED_CODE_TS_RE = /error\s+TS6(?:133|192|196|198|199|205)\b/i;
68
+
55
69
  function isRealCompileError(line) {
56
70
  const l = String(line);
71
+ if (UNUSED_CODE_TS_RE.test(l)) return false;
57
72
  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
73
  }
59
74
 
@@ -66,6 +81,8 @@ function isRealCompileError(line) {
66
81
  function isLintError(line) {
67
82
  const l = String(line);
68
83
  if (isRealCompileError(l)) return false;
84
+ // Unused-symbol strictness (see UNUSED_CODE_TS_RE) is style, not brokenness.
85
+ if (UNUSED_CODE_TS_RE.test(l)) return true;
69
86
  // Reference to a known ESLint rule namespace / a bare `no-*` rule.
70
87
  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
88
  // ESLint compact format "12:5 Error message rule" without a TS error code.
@@ -98,6 +98,66 @@ function readFileSafe(appRoot, fileRel) {
98
98
  }
99
99
  }
100
100
 
101
+ const SOURCE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte'];
102
+
103
+ /** Read tsconfig/jsconfig path aliases once per appRoot: [{ prefix, target }]. */
104
+ const aliasCache = new Map();
105
+ function readPathAliases(appRoot) {
106
+ if (aliasCache.has(appRoot)) return aliasCache.get(appRoot);
107
+ const aliases = [];
108
+ for (const cfgName of ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json']) {
109
+ try {
110
+ const raw = fs.readFileSync(path.join(appRoot, cfgName), 'utf8');
111
+ // Tolerant parse: strip comments and trailing commas (tsconfig is JSONC).
112
+ const cfg = JSON.parse(raw.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '').replace(/,\s*([}\]])/g, '$1'));
113
+ const co = cfg.compilerOptions || {};
114
+ const baseUrl = co.baseUrl || '.';
115
+ for (const [key, targets] of Object.entries(co.paths || {})) {
116
+ const target = Array.isArray(targets) ? targets[0] : targets;
117
+ if (!target) continue;
118
+ aliases.push({
119
+ prefix: key.replace(/\*$/, ''),
120
+ target: normRel(path.join(baseUrl, String(target).replace(/\*$/, ''))),
121
+ });
122
+ }
123
+ } catch {
124
+ /* no config or unparsable — skip */
125
+ }
126
+ }
127
+ aliasCache.set(appRoot, aliases);
128
+ return aliases;
129
+ }
130
+
131
+ /**
132
+ * Resolve an AI-proposed import path to a REAL file, tolerating what a human
133
+ * would write: tsconfig path aliases ('@/context/AppProvider') and
134
+ * extensionless specifiers. Returns the resolved app-relative path (with
135
+ * extension) or null. Rejecting these outright un-wired real actions whose
136
+ * only "fault" was that the AI wrote the import the way the codebase does.
137
+ */
138
+ function resolveSourceFile(appRoot, fromRel) {
139
+ const candidates = [normRel(fromRel)];
140
+ for (const { prefix, target } of readPathAliases(appRoot)) {
141
+ const f = candidates[0];
142
+ if (f === prefix.replace(/\/$/, '') || f.startsWith(prefix)) {
143
+ candidates.push(normRel(target + f.slice(prefix.length)));
144
+ }
145
+ }
146
+ for (const rel of candidates) {
147
+ const abs = path.join(appRoot, rel);
148
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return rel;
149
+ for (const ext of SOURCE_EXTS) {
150
+ if (fs.existsSync(`${abs}${ext}`)) return `${rel}${ext}`;
151
+ }
152
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
153
+ for (const ext of SOURCE_EXTS) {
154
+ if (fs.existsSync(path.join(abs, `index${ext}`))) return normRel(path.join(rel, `index${ext}`));
155
+ }
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+
101
161
  /** Count non-overlapping occurrences of a literal substring. */
102
162
  function countOccurrences(haystack, needle) {
103
163
  if (!needle) return 0;
@@ -112,14 +172,15 @@ function countOccurrences(haystack, needle) {
112
172
 
113
173
  // ── Mechanical verification (the safety net for AI-proposed wiring) ─────────────
114
174
 
115
- /** True if `symbol` appears as a standalone identifier somewhere in the file. */
175
+ /** True if `symbol` appears as a standalone identifier somewhere in the file.
176
+ * The file path tolerates aliases/extensionless specifiers (resolveSourceFile). */
116
177
  function fileHasSymbol(appRoot, fileRel, symbol) {
117
178
  if (!fileRel || !symbol) return false;
118
- const abs = path.join(appRoot, normRel(fileRel));
119
- if (!fs.existsSync(abs)) return false;
179
+ const resolved = resolveSourceFile(appRoot, fileRel);
180
+ if (!resolved) return false;
120
181
  let content;
121
182
  try {
122
- content = fs.readFileSync(abs, 'utf8');
183
+ content = fs.readFileSync(path.join(appRoot, resolved), 'utf8');
123
184
  } catch {
124
185
  return false;
125
186
  }
@@ -419,11 +480,12 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
419
480
  const importedNames = new Set();
420
481
  for (const imp of rawImports) {
421
482
  const name = String((imp && imp.name) || '').trim();
422
- const from = normRel(imp && imp.from);
483
+ const rawFrom = normRel(imp && imp.from);
423
484
  const kind = (imp && imp.kind) || 'named';
424
- if (!name || !from) return { reason: 'an import proposed by the AI is incomplete (missing name/from)' };
425
- if (!fs.existsSync(path.join(appRoot, from))) {
426
- return { reason: `the proposed import points to a non-existent file: '${from}'` };
485
+ if (!name || !rawFrom) return { reason: 'an import proposed by the AI is incomplete (missing name/from)' };
486
+ const from = resolveSourceFile(appRoot, rawFrom);
487
+ if (!from) {
488
+ return { reason: `the proposed import points to a non-existent file: '${rawFrom}'` };
427
489
  }
428
490
  // For named imports the symbol must be present in that file; default/namespace
429
491
  // bind the module's default/whole export so only file existence is checked.
@@ -514,6 +576,36 @@ function deriveBridgeAnchor(content, invoke) {
514
576
  return null;
515
577
  }
516
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
+
517
609
  /**
518
610
  * True when an anchor that appears several times is HARMLESS: every occurrence
519
611
  * resolves to the same actual insertion point (the nearest JSX `return`), so
@@ -554,9 +646,15 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
554
646
  const why = aiW.bridgeReason ? ` (${aiW.bridgeReason})` : '';
555
647
 
556
648
  if (!file) return { reason: `the AI marked a bridge but did not indicate the component file${why}` };
557
- const content = readFileSafe(appRoot, file);
558
- if (content == null) return { reason: `could not read the component '${file}' to apply the bridge` };
649
+ const rawContent = readFileSafe(appRoot, file);
650
+ if (rawContent == null) return { reason: `could not read the component '${file}' to apply the bridge` };
559
651
  if (!invoke) return { reason: `the AI did not provide the bridge 'invoke' expression for '${file}'` };
652
+ // Anchor decisions must be made on the content the block will ACTUALLY be
653
+ // inserted into: applyComponentEdits strips our previous blocks first. On a
654
+ // re-run over an already-wired file, the old generated block contains the
655
+ // same invoke expressions, so counting occurrences on the raw file falsely
656
+ // flags unique anchors as "ambiguous" and un-wires working actions.
657
+ const content = stripFiodosBlocks(rawContent);
560
658
 
561
659
  // Anchor resolution ladder: exact-unique AI anchor → harmless-ambiguous AI
562
660
  // anchor → mechanically derived declaration anchor → review. The old behavior
@@ -566,7 +664,13 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
566
664
  const occ = anchor ? countOccurrences(content, anchor) : 0;
567
665
  if (occ !== 1) {
568
666
  const scope = b.scope === 'class-field' ? 'class-field' : 'statement';
569
- if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
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)) {
570
674
  // Keep the AI anchor: every occurrence lands on the same insertion point.
571
675
  } else {
572
676
  const derived = deriveBridgeAnchor(content, invoke);
@@ -604,10 +708,18 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
604
708
  const importedNames = new Set();
605
709
  for (const imp of Array.isArray(b.imports) ? b.imports : []) {
606
710
  const name = String((imp && imp.name) || '').trim();
607
- const from = normRel(imp && imp.from);
711
+ const rawFrom = normRel(imp && imp.from);
608
712
  const kind = (imp && imp.kind) || 'named';
609
- if (!name || !from) return { reason: 'a bridge import is incomplete (missing name/from)' };
610
- if (!fs.existsSync(path.join(appRoot, from))) return { reason: `the bridge import points to a non-existent file: '${from}'` };
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
+ }
721
+ const from = resolveSourceFile(appRoot, rawFrom);
722
+ if (!from) return { reason: `the bridge import points to a non-existent file: '${rawFrom}'` };
611
723
  if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) return { reason: `named import '${name}' does not exist in '${from}'` };
612
724
  imports.push({ kind, name, importPath: relImport(path.dirname(file), from) });
613
725
  importedNames.add(name);
@@ -1195,10 +1307,31 @@ function buildHandlerDoc(plan, ctx = {}) {
1195
1307
 
1196
1308
  // ── Editing the user's own files (idempotent + reversible) ─────────────────────
1197
1309
 
1198
- /** Remove any previously-inserted Fiodos blocks (idempotence across re-runs). */
1310
+ /**
1311
+ * Header phrasings the CLI itself has ever written (current English + the
1312
+ * localized parenthetical of older versions). Only blocks carrying one of
1313
+ * these are OURS to strip.
1314
+ */
1315
+ const GENERATED_HEADER_RE = /(generated by|auto-generado por|generado por)\s+Fiodos/i;
1316
+
1317
+ /**
1318
+ * Remove previously-inserted Fiodos blocks (idempotence across re-runs) —
1319
+ * but ONLY the blocks this CLI generated. A developer can hand-write their
1320
+ * own `FYODOS:BRIDGE` block (e.g. "manual wiring — logout") following our
1321
+ * docs; that block is THEIR code and often the only user of an import
1322
+ * (`useEffect`, a hook…). Stripping it orphans the import, the strict
1323
+ * TypeScript check (noUnusedLocals) fails the candidate, and the whole
1324
+ * wiring reverts — the exact failure that left navigate_section/logout
1325
+ * unwired in production. Developer blocks are preserved verbatim.
1326
+ */
1199
1327
  function stripFiodosBlocks(content) {
1200
- const re = new RegExp(`\\n?${esc(EDIT_START_PREFIX)}[^\\n]*[\\s\\S]*?${esc(EDIT_END)}\\n?`, 'g');
1201
- return content.replace(re, '\n');
1328
+ const re = new RegExp(
1329
+ `\\n?(${esc(EDIT_START_PREFIX)}[^\\n]*)[\\s\\S]*?${esc(EDIT_END)}\\n?`,
1330
+ 'g',
1331
+ );
1332
+ return content.replace(re, (block, headerLine) =>
1333
+ GENERATED_HEADER_RE.test(headerLine) ? '\n' : block,
1334
+ );
1202
1335
  }
1203
1336
 
1204
1337
  /** Insert an import block right after the file's last top-level import line. */
@@ -1261,6 +1394,26 @@ function insertRegisterBlock(content, file, anchor, block, scope) {
1261
1394
  return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
1262
1395
  }
1263
1396
 
1397
+ // When the anchor's own line is unmistakably a body STATEMENT (a declaration
1398
+ // or a bare call ending in `;`), insert right AFTER it. Snapping to the
1399
+ // nearest `return` instead can land the registration inside a conditional
1400
+ // early-return branch (`if (authLoading) { … return <Spinner/> }`): it
1401
+ // compiles, but in normal use that branch never runs and the bridge is never
1402
+ // registered — the action silently dies at runtime.
1403
+ const anchorLineStart = content.lastIndexOf('\n', at) + 1;
1404
+ const anchorLineEnd = content.indexOf('\n', at);
1405
+ const anchorLine = content.slice(anchorLineStart, anchorLineEnd === -1 ? content.length : anchorLineEnd).trim();
1406
+ const isStatementLine =
1407
+ /^(const|let|var)\b.*;\s*$/.test(anchorLine) || /^[\w$][\w$.]*\((?:[^()]|\([^()]*\))*\);\s*$/.test(anchorLine);
1408
+ if (isStatementLine) {
1409
+ // A multi-line declaration (`const { a, b } =\n useApp();`) ends at the
1410
+ // first `;` at/after the anchor, not at the anchor's own line end.
1411
+ const semi = content.indexOf(';', at);
1412
+ const stmtEnd = semi === -1 ? (anchorLineEnd === -1 ? content.length : anchorLineEnd) : content.indexOf('\n', semi);
1413
+ const endIdx = stmtEnd === -1 ? content.length : stmtEnd;
1414
+ return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
1415
+ }
1416
+
1264
1417
  const ret = nearestJsxReturn(content, at);
1265
1418
  if (ret !== -1) {
1266
1419
  const lineStart = content.lastIndexOf('\n', ret) + 1;
@@ -1410,24 +1563,64 @@ function pickRelevantFiles(files, evidence, candidate, intent) {
1410
1563
  for (const imp of (candidate.bridge && candidate.bridge.imports) || []) if (imp && imp.from) wanted.add(normRel(imp.from));
1411
1564
  for (const r of candidate.requiredSymbols || []) if (r && r.file) wanted.add(normRel(r.file));
1412
1565
  }
1566
+ // The most common unfixable failure is "symbol X is not available in the
1567
+ // proposed file": the fix lives in ANOTHER component that consumes X. Give
1568
+ // the corrector every file that mentions the invoked symbols (capped) so it
1569
+ // can retarget the bridge instead of blindly retrying the same file.
1570
+ const invokeSrc = String(
1571
+ (candidate && ((candidate.bridge && candidate.bridge.invoke) || candidate.call)) || '',
1572
+ );
1573
+ const symbols = new Set();
1574
+ let sm;
1575
+ const symRe = /(?:^|[^.\w$])([A-Za-z_$][\w$]{2,})\s*\(/g;
1576
+ while ((sm = symRe.exec(invokeSrc))) {
1577
+ if (!JS_KEYWORDS.has(sm[1]) && !SAFE_GLOBALS.has(sm[1])) symbols.add(sm[1]);
1578
+ }
1579
+ if (symbols.size) {
1580
+ let extra = 0;
1581
+ for (const f of files) {
1582
+ if (extra >= 4) break;
1583
+ if (wanted.has(normRel(f.rel))) continue;
1584
+ const content = String(f.content || '');
1585
+ for (const sym of symbols) {
1586
+ if (new RegExp(`(^|[^\\w$])${esc(sym)}([^\\w$]|$)`).test(content)) {
1587
+ wanted.add(normRel(f.rel));
1588
+ extra += 1;
1589
+ break;
1590
+ }
1591
+ }
1592
+ }
1593
+ }
1413
1594
  const picked = files.filter((f) => wanted.has(normRel(f.rel)));
1414
1595
  return picked.length ? picked : files.slice(0, 4);
1415
1596
  }
1416
1597
 
1417
- /** Write ONLY one entry (registry + bridge + its edit) for isolated verification. */
1598
+ /**
1599
+ * Write ONLY one entry (registry + bridge + its edit) for isolated verification.
1600
+ * Registry/bridge files from a PREVIOUS install are snapshotted and restored on
1601
+ * revert instead of deleted: deleting them would break every OTHER file that
1602
+ * still imports `../fyodos/bridge` from an earlier run, making all later
1603
+ * verifications fail with a phantom "Cannot find module" (TS2307).
1604
+ */
1418
1605
  function writeIsolated({ appRoot, fyodosDirAbs, entry, ts, esm, ext }) {
1419
1606
  const onePlan = { auto: [entry], review: [], manual: [], entries: [entry] };
1420
1607
  const registryAbs = path.join(fyodosDirAbs, `${REGISTRY_BASENAME}${ext}`);
1421
1608
  const bridgeAbs = path.join(fyodosDirAbs, `${BRIDGE_BASENAME}${ext}`);
1422
- const generated = [registryAbs];
1609
+ const priorBackups = [];
1610
+ const generated = [];
1611
+ const snapshotOrCreate = (abs) => {
1612
+ if (fs.existsSync(abs)) priorBackups.push({ file: abs, abs, original: fs.readFileSync(abs, 'utf8') });
1613
+ else generated.push(abs);
1614
+ };
1615
+ snapshotOrCreate(registryAbs);
1423
1616
  if (entry.kind === 'bridge') {
1617
+ snapshotOrCreate(bridgeAbs);
1424
1618
  fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
1425
- generated.push(bridgeAbs);
1426
1619
  }
1427
1620
  fs.writeFileSync(registryAbs, buildRegistryModule(onePlan, { ts, esm }));
1428
1621
  const edits = planComponentEdits(appRoot, onePlan, { ts });
1429
1622
  const { backups } = applyComponentEdits(appRoot, edits);
1430
- return { backups, generated, touched: [...edits.map((e) => e.file), registryAbs, bridgeAbs] };
1623
+ return { backups: [...backups, ...priorBackups], generated, touched: [...edits.map((e) => e.file), registryAbs, bridgeAbs] };
1431
1624
  }
1432
1625
 
1433
1626
  /**
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
 
@@ -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);