@fiodos/cli 0.1.25 → 0.1.26

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.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Shopify theme support — the closed-platform branch of the analyze pipeline.
3
+ *
4
+ * A Shopify store's "codebase" is its Liquid theme (GitHub-synced via
5
+ * Shopify's native integration), and its live catalog is PUBLIC JSON
6
+ * (/collections.json, /products.json). This module:
7
+ *
8
+ * 1. detects a theme repo (layout/theme.liquid + templates/),
9
+ * 2. fetches a public storefront snapshot and exposes it as synthetic FILE
10
+ * blocks (_fyodos/storefront/*.json) so the analyzer can personalize
11
+ * routes/bubbles/examples to the REAL catalog — and so evidence
12
+ * verification has concrete files to point at,
13
+ * 3. injects the embed snippet into layout/theme.liquid (idempotent
14
+ * FYODOS:ORB markers, same pattern as every other wire step), including
15
+ * a Liquid-rendered screen context so the agent knows the visible
16
+ * product's variant id (what add_to_cart needs).
17
+ *
18
+ * No wiring/session/screen blocks apply here: commerce actions carry
19
+ * declarative `execution` templates resolved by buildApiActionRegistries in
20
+ * the embed at runtime ({baseUrl} → the store's own origin).
21
+ */
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ /** CDN URL of the self-contained embed bundle (global `Fiodos`). Keep in sync
28
+ * with dashboard/src/lib/shopifyInstall.ts. */
29
+ const FIODOS_EMBED_CDN_URL =
30
+ 'https://unpkg.com/@fiodos/web-core@latest/dist/embed/fiodos-embed.js';
31
+
32
+ const ORB_START = '<!-- FYODOS:ORB:START (Fiodos agent — safe to remove) -->';
33
+ const ORB_END = '<!-- FYODOS:ORB:END -->';
34
+
35
+ /** Repo-relative folder of the synthetic storefront snapshot files. */
36
+ const SNAPSHOT_DIR = '_fyodos/storefront';
37
+
38
+ /** A Shopify theme repo is unmistakable: layout/theme.liquid is the layout
39
+ * every Online Store 2.0 theme must have, plus templates/ or sections/. */
40
+ function isShopifyThemeRoot(appRoot) {
41
+ const has = (rel) => {
42
+ try {
43
+ return fs.existsSync(path.join(appRoot, rel));
44
+ } catch {
45
+ return false;
46
+ }
47
+ };
48
+ return (
49
+ has('layout/theme.liquid') &&
50
+ (has('templates') || has('sections') || has('config/settings_schema.json'))
51
+ );
52
+ }
53
+
54
+ /** Store name from the theme's settings_schema.json (theme_info), best-effort. */
55
+ function readThemeMeta(appRoot) {
56
+ const meta = {};
57
+ try {
58
+ const raw = fs.readFileSync(path.join(appRoot, 'config', 'settings_schema.json'), 'utf8');
59
+ const schema = JSON.parse(raw);
60
+ const info = Array.isArray(schema)
61
+ ? schema.find((s) => s && (s.name === 'theme_info' || s.theme_name))
62
+ : null;
63
+ if (info && info.theme_name) meta.themeName = String(info.theme_name);
64
+ } catch {
65
+ /* not required */
66
+ }
67
+ return meta;
68
+ }
69
+
70
+ function normalizeStoreDomain(raw) {
71
+ const s = String(raw || '').trim();
72
+ if (!s) return '';
73
+ return s
74
+ .replace(/^https?:\/\//i, '')
75
+ .replace(/\/.*$/, '')
76
+ .toLowerCase();
77
+ }
78
+
79
+ async function fetchJson(url, { timeoutMs = 8000 } = {}) {
80
+ const controller = new AbortController();
81
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
82
+ try {
83
+ const res = await fetch(url, {
84
+ signal: controller.signal,
85
+ headers: { accept: 'application/json' },
86
+ });
87
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
88
+ return await res.json();
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
93
+
94
+ function stripHtml(html) {
95
+ return String(html || '')
96
+ .replace(/<[^>]*>/g, ' ')
97
+ .replace(/\s+/g, ' ')
98
+ .trim();
99
+ }
100
+
101
+ /** The universal storefront surface, written into the snapshot so evidence
102
+ * ("_fyodos/storefront/storefront.json", symbol "shopify_storefront") is
103
+ * backed by a real file among the ones sent to the model. */
104
+ function universalSurfaceDoc(storeDomain) {
105
+ return {
106
+ shopify_storefront: true,
107
+ store: storeDomain || '(unknown — no --store domain provided)',
108
+ universalRoutes: ['/', '/collections/all', '/search', '/cart'],
109
+ universalActions: [
110
+ { intent: 'search_products', handler: 'shopifySearchProducts', api: '{baseUrl}/search/suggest.json' },
111
+ { intent: 'add_to_cart', handler: 'shopifyAddToCart', api: '{baseUrl}/cart/add.js' },
112
+ { intent: 'get_cart', handler: 'shopifyGetCart', api: '{baseUrl}/cart.js' },
113
+ { intent: 'update_cart_item', handler: 'shopifyUpdateCartItem', api: '{baseUrl}/cart/change.js' },
114
+ { intent: 'go_checkout', kind: 'link', navTarget: '/checkout' },
115
+ ],
116
+ note:
117
+ 'Universal Shopify storefront surface — present on every Shopify store ' +
118
+ 'regardless of theme. Ajax Cart API + Predictive Search, anonymous access.',
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Fetch the public storefront snapshot for a store and return it as synthetic
124
+ * collection entries ({ rel, content, tier, chars }) ready to append to the
125
+ * collector's `included` list.
126
+ *
127
+ * Fail-open: any fetch error still yields storefront.json (the universal
128
+ * surface), so the analysis and its evidence contract always work — the
129
+ * manifest just loses catalog personalization when the store is unreachable.
130
+ */
131
+ async function fetchStorefrontSnapshot(storeRaw, { log = () => {} } = {}) {
132
+ const store = normalizeStoreDomain(storeRaw);
133
+ const files = [];
134
+ const pushFile = (name, data) => {
135
+ const content = JSON.stringify(data, null, 2);
136
+ files.push({ rel: `${SNAPSHOT_DIR}/${name}`, content, tier: 0, chars: content.length });
137
+ };
138
+
139
+ const warnings = [];
140
+ let collections = [];
141
+ let products = [];
142
+ if (store) {
143
+ const base = `https://${store}`;
144
+ try {
145
+ const data = await fetchJson(`${base}/collections.json?limit=250`);
146
+ collections = (data.collections || []).map((c) => ({
147
+ handle: c.handle,
148
+ title: c.title,
149
+ description: stripHtml(c.body_html).slice(0, 200),
150
+ productsCount: c.products_count,
151
+ updatedAt: c.updated_at,
152
+ }));
153
+ } catch (e) {
154
+ warnings.push(`collections.json: ${e.message}`);
155
+ }
156
+ try {
157
+ const data = await fetchJson(`${base}/products.json?limit=50`);
158
+ products = (data.products || []).map((p) => ({
159
+ handle: p.handle,
160
+ title: p.title,
161
+ productType: p.product_type,
162
+ vendor: p.vendor,
163
+ tags: p.tags,
164
+ description: stripHtml(p.body_html).slice(0, 200),
165
+ priceRange: (() => {
166
+ const prices = (p.variants || []).map((v) => Number(v.price)).filter((n) => !Number.isNaN(n));
167
+ if (!prices.length) return undefined;
168
+ return { min: Math.min(...prices), max: Math.max(...prices) };
169
+ })(),
170
+ variantSample: (p.variants || []).slice(0, 3).map((v) => ({ id: v.id, title: v.title, price: v.price })),
171
+ }));
172
+ } catch (e) {
173
+ warnings.push(`products.json: ${e.message}`);
174
+ }
175
+ } else {
176
+ warnings.push('no store domain provided (--store) — catalog personalization limited to theme files');
177
+ }
178
+
179
+ pushFile('storefront.json', {
180
+ ...universalSurfaceDoc(store),
181
+ fetchedAt: new Date().toISOString(),
182
+ collectionsCount: collections.length,
183
+ productSampleCount: products.length,
184
+ ...(warnings.length ? { warnings } : {}),
185
+ });
186
+ if (collections.length) pushFile('collections.json', { collections });
187
+ if (products.length) pushFile('products.json', { products });
188
+
189
+ for (const w of warnings) log(`[shopify] snapshot warning: ${w}`);
190
+ log(
191
+ `[shopify] storefront snapshot: ${collections.length} collections, ` +
192
+ `${products.length} product sample${store ? ` (${store})` : ''}`,
193
+ );
194
+ return { files, store, collections, products, warnings };
195
+ }
196
+
197
+ /**
198
+ * The theme.liquid block: loads the embed, fetches the published manifest and
199
+ * mounts the orb with API-wired registries. Mirrors the dashboard's
200
+ * buildShopifySnippet, plus a Liquid-rendered PRODUCT screen context so the
201
+ * agent knows the visible variant id (what add_to_cart needs by voice).
202
+ */
203
+ function buildShopifySnippet({ apiKey, apiUrl }) {
204
+ const key = apiKey || '<your-api-key>';
205
+ const url = String(apiUrl || '').replace(/\/$/, '');
206
+ return [
207
+ ORB_START,
208
+ `<script src="${FIODOS_EMBED_CDN_URL}" defer></script>`,
209
+ '<script>',
210
+ " {% if template contains 'product' and product %}",
211
+ ' {% capture fyodos_screen_text %}The customer is viewing the product page of: {{ product.title }} (price {{ product.price | money | strip_html }}). Add-to-cart variantId: {{ product.selected_or_first_available_variant.id }}.{% endcapture %}',
212
+ " window.__fyodosScreen = { kind: 'product', text: {{ fyodos_screen_text | json }} };",
213
+ ' {% endif %}',
214
+ " window.addEventListener('DOMContentLoaded', function () {",
215
+ ` var FYODOS_API_KEY = '${key}';`,
216
+ ` var FYODOS_API_URL = '${url}';`,
217
+ " fetch(FYODOS_API_URL + '/v1/client/manifest', { headers: { 'x-api-key': FYODOS_API_KEY } })",
218
+ ' .then(function (r) { return r.json(); })',
219
+ ' .then(function (data) {',
220
+ ' if (!data || !data.manifest || !window.Fiodos) return;',
221
+ ' var agent = Fiodos.createFiodosAgent({',
222
+ ' baseUrl: FYODOS_API_URL,',
223
+ ' apiKey: FYODOS_API_KEY,',
224
+ ' manifest: data.manifest,',
225
+ ' registries: Fiodos.buildApiActionRegistries(data.manifest, {',
226
+ ' baseUrl: window.location.origin,',
227
+ ' }),',
228
+ " localeDetection: 'document',",
229
+ ' });',
230
+ ' if (window.__fyodosScreen) agent.setScreenContext(window.__fyodosScreen);',
231
+ ' });',
232
+ ' });',
233
+ '</script>',
234
+ ORB_END,
235
+ ].join('\n');
236
+ }
237
+
238
+ function escapeRe(s) {
239
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
240
+ }
241
+
242
+ /**
243
+ * Inject (or refresh) the snippet in layout/theme.liquid, right before
244
+ * </body>. Idempotent via the FYODOS:ORB markers: an existing block is
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? }.
248
+ */
249
+ function wireShopifyTheme(appRoot, { apiKey, apiUrl, noWire = false } = {}) {
250
+ const relFile = path.join('layout', 'theme.liquid');
251
+ const file = path.join(appRoot, relFile);
252
+ if (!fs.existsSync(file)) {
253
+ return { status: 'failed', file: relFile, reason: 'layout/theme.liquid not found' };
254
+ }
255
+ const snippet = buildShopifySnippet({ apiKey, apiUrl });
256
+ const source = fs.readFileSync(file, 'utf8');
257
+
258
+ const markerRe = new RegExp(`${escapeRe(ORB_START)}[\\s\\S]*?${escapeRe(ORB_END)}`);
259
+ if (markerRe.test(source)) {
260
+ const existing = source.match(markerRe)[0];
261
+ if (existing === snippet) return { status: 'already', file: relFile };
262
+ if (noWire) return { status: 'skipped', file: relFile, reason: '--no-wire' };
263
+ fs.writeFileSync(file, source.replace(markerRe, snippet));
264
+ return { status: 'added', file: relFile, refreshed: true };
265
+ }
266
+
267
+ if (noWire) return { status: 'skipped', file: relFile, reason: '--no-wire' };
268
+ const bodyClose = source.lastIndexOf('</body>');
269
+ const next = bodyClose === -1
270
+ ? `${source}\n${snippet}\n`
271
+ : `${source.slice(0, bodyClose)}${snippet}\n ${source.slice(bodyClose)}`;
272
+ fs.writeFileSync(file, next);
273
+ return { status: 'added', file: relFile };
274
+ }
275
+
276
+ function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
277
+ const { blue = '', cyan = '', reset = '' } = colors;
278
+ const say = (line) => console.error(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
279
+ if (result.status === 'added') {
280
+ say(`orb embedded in ${result.file}${result.refreshed ? ' (snippet refreshed)' : ''} — commit & push to deploy via Shopify's GitHub sync`);
281
+ } else if (result.status === 'already') {
282
+ say(`orb already embedded in ${result.file}`);
283
+ } else if (result.status === 'failed') {
284
+ say(`could not embed the orb: ${result.reason}`);
285
+ } else if (!quiet) {
286
+ say(`orb embed skipped (${result.reason || result.status})`);
287
+ }
288
+ }
289
+
290
+ module.exports = {
291
+ isShopifyThemeRoot,
292
+ readThemeMeta,
293
+ fetchStorefrontSnapshot,
294
+ buildShopifySnippet,
295
+ wireShopifyTheme,
296
+ reportShopifyWire,
297
+ normalizeStoreDomain,
298
+ FIODOS_EMBED_CDN_URL,
299
+ SNAPSHOT_DIR,
300
+ };
package/src/verify.js CHANGED
@@ -31,6 +31,40 @@ function fileHasIdentifier(content, identifier) {
31
31
  return re.test(content);
32
32
  }
33
33
 
34
+ const EXECUTION_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
35
+
36
+ /**
37
+ * Structural check of a declarative `execution` block (closed platforms).
38
+ * Mirrors @fiodos/core validateExecution: http kind, known method, non-empty
39
+ * url, and every {placeholder} resolvable (baseUrl or a declared parameter).
40
+ * Returns a problem string, or null when the block is sound.
41
+ */
42
+ function validateExecutionShape(action) {
43
+ const exec = action.execution;
44
+ if (!exec || typeof exec !== 'object') return 'missing execution object';
45
+ if (exec.kind !== 'http') return `kind '${exec.kind}' is not 'http'`;
46
+ if (!EXECUTION_METHODS.has(exec.method)) return `invalid method '${exec.method}'`;
47
+ if (typeof exec.url !== 'string' || !exec.url.trim()) return 'empty url';
48
+ if (!String(action.handler || '').trim()) return 'function action has no handler name';
49
+ const declared = new Set(Object.keys(action.parameters || {}));
50
+ const placeholders = new Set();
51
+ const collect = (value) => {
52
+ if (typeof value === 'string') {
53
+ for (const m of value.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) placeholders.add(m[1]);
54
+ } else if (value && typeof value === 'object') {
55
+ for (const v of Object.values(value)) collect(v);
56
+ }
57
+ };
58
+ collect(exec.url);
59
+ collect(exec.body);
60
+ for (const name of placeholders) {
61
+ if (name !== 'baseUrl' && !declared.has(name)) {
62
+ return `execution references undeclared parameter '{${name}}'`;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
34
68
  /**
35
69
  * Upper bound on examples kept per route/action. The enriched prompt asks the
36
70
  * model for 5-8 varied phrasings; this cap protects the cacheable system prompt
@@ -160,12 +194,48 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
160
194
  });
161
195
  }
162
196
 
197
+ const isShopify = opts.platform === 'shopify';
163
198
  const actions = [];
164
199
  for (const action of manifest.actions || []) {
165
200
  const ev = evidence.actions?.[action.intent] || {};
166
201
  const evFile = String(ev.file || '').replace(/\\/g, '/').replace(/^\.\//, '');
167
202
  const content = byRel.get(evFile);
168
203
 
204
+ // Shopify (closed platform): the ground truth for API actions is the
205
+ // platform itself, not app code. An action carrying a declarative
206
+ // `execution` block is verified STRUCTURALLY (http kind + method + url and
207
+ // every {placeholder} declared) — a synthetic handler name can never match
208
+ // an identifier in a .liquid file, and requiring it would drop the whole
209
+ // universal commerce set. The universal "/checkout" link is likewise
210
+ // platform-guaranteed (themes rarely hardcode that href).
211
+ if (isShopify && action.kind !== 'link' && action.execution) {
212
+ const problem = validateExecutionShape(action);
213
+ if (problem) {
214
+ provenance.actions.push({
215
+ intent: action.intent, included: false,
216
+ reason: `invalid execution block — ${problem} — DROPPED`,
217
+ });
218
+ continue;
219
+ }
220
+ actions.push(action);
221
+ provenance.actions.push({
222
+ intent: action.intent, included: true,
223
+ verification: 'declarative execution (Shopify storefront API — platform-guaranteed)',
224
+ evidence: ev,
225
+ });
226
+ continue;
227
+ }
228
+ if (isShopify && action.kind === 'link' && String(action.navTarget || '').trim() === '/checkout') {
229
+ action.handler = '';
230
+ actions.push(action);
231
+ provenance.actions.push({
232
+ intent: action.intent, included: true,
233
+ verification: 'universal Shopify checkout link (platform-guaranteed)',
234
+ evidence: ev,
235
+ });
236
+ continue;
237
+ }
238
+
169
239
  if (!content) {
170
240
  provenance.actions.push({
171
241
  intent: action.intent, included: false,
@@ -254,6 +324,59 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
254
324
  else delete route.actionIntents;
255
325
  }
256
326
 
327
+ // Escort target: keep each action's showcaseRoute fail-open. It must point
328
+ // at a SURVIVING, navigable route intent (never BACK); anything else —
329
+ // hallucinated ids, dropped routes, non-strings — is silently removed so a
330
+ // wrong escort can never ship (a wrong screen is worse than no escort).
331
+ const navigableRouteIntents = new Set(
332
+ dedupedRoutes.filter((r) => r.route !== 'BACK').map((r) => r.intent),
333
+ );
334
+ for (const action of dedupedActions) {
335
+ if (action.showcaseRoute === undefined) continue;
336
+ if (typeof action.showcaseRoute !== 'string' || !navigableRouteIntents.has(action.showcaseRoute)) {
337
+ provenance.notes.push(
338
+ `action '${action.intent}': showcaseRoute '${String(action.showcaseRoute)}' does not match a navigable route — removed`,
339
+ );
340
+ delete action.showcaseRoute;
341
+ }
342
+ }
343
+
344
+ // Audience map (fail-open): only the two known values survive; anything else
345
+ // is dropped so an invalid claim can never HIDE a capability from a user.
346
+ // 'public' is the implicit default, so it is normalized away (absent = public).
347
+ // requiredCapabilities become a deduped list of short snake_case names; a
348
+ // malformed list is dropped entirely (a bogus capability must never hide an
349
+ // action behind a flag no session will ever report).
350
+ for (const item of [...dedupedRoutes, ...dedupedActions]) {
351
+ if (item.audience !== undefined && item.audience !== 'authenticated') {
352
+ if (item.audience !== 'public') {
353
+ provenance.notes.push(
354
+ `'${item.intent}': audience '${String(item.audience)}' is not public/authenticated — removed`,
355
+ );
356
+ }
357
+ delete item.audience;
358
+ }
359
+ }
360
+ for (const action of dedupedActions) {
361
+ if (action.requiredCapabilities === undefined) continue;
362
+ const caps = Array.isArray(action.requiredCapabilities)
363
+ ? [
364
+ ...new Set(
365
+ action.requiredCapabilities
366
+ .filter((c) => typeof c === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(c.trim()))
367
+ .map((c) => c.trim()),
368
+ ),
369
+ ].slice(0, 8)
370
+ : [];
371
+ if (caps.length > 0) action.requiredCapabilities = caps;
372
+ else {
373
+ provenance.notes.push(
374
+ `action '${action.intent}': requiredCapabilities had no valid snake_case names — removed`,
375
+ );
376
+ delete action.requiredCapabilities;
377
+ }
378
+ }
379
+
257
380
  // Examples hygiene (fail-open): trim, dedupe and cap the enriched phrasings on
258
381
  // every surviving route and action. Never drops the item; falls back to the
259
382
  // label so the core validator's non-empty-examples rule still holds.
@@ -299,4 +422,4 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
299
422
  };
300
423
  }
301
424
 
302
- module.exports = { verifyManifest, normalizeHref, sanitizeExamples, MAX_EXAMPLES };
425
+ module.exports = { verifyManifest, normalizeHref, sanitizeExamples, validateExecutionShape, MAX_EXAMPLES };
@@ -467,13 +467,79 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
467
467
  };
468
468
  }
469
469
 
470
+ /**
471
+ * MECHANICAL ANCHOR RESCUE — the fix for the dominant wiring failure class
472
+ * ("the insertion point in '<file>' is ambiguous / not found").
473
+ *
474
+ * The AI's anchor only needs to locate the RIGHT COMPONENT SCOPE: the actual
475
+ * insertion goes before the nearest JSX `return` (see insertRegisterBlock), so
476
+ * when the AI picks a line that appears several times in a big file we do not
477
+ * have to give up. We derive a deterministic anchor ourselves: the line where
478
+ * one of the identifiers the `invoke` expression CALLS/READS is DECLARED
479
+ * (`const [x, setX] = useState(...)`, `function foo(`, `const foo =`, a class
480
+ * field, …). Declarations are unique per scope in real code, and anchoring on
481
+ * the declaration guarantees the registration lands in the scope where the
482
+ * symbol is visible. Correctness is still gated by the same compile-regression
483
+ * + effect-probe verification as every other wiring — this only replaces the
484
+ * "reject" with a better, mechanical candidate.
485
+ */
486
+ function deriveBridgeAnchor(content, invoke) {
487
+ const src = stripLiterals(String(invoke || ''));
488
+ // Identifiers the invoke CALLS first (most specific), then everything it reads.
489
+ const ordered = [];
490
+ const pushId = (id) => {
491
+ if (!id || id === 'args' || id === 'this') return;
492
+ if (JS_KEYWORDS.has(id) || SAFE_GLOBALS.has(id)) return;
493
+ if (!ordered.includes(id)) ordered.push(id);
494
+ };
495
+ let m;
496
+ const callRe = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
497
+ while ((m = callRe.exec(src))) pushId(m[1]);
498
+ for (const id of freeIdentifiers(invoke)) pushId(id);
499
+
500
+ for (const id of ordered) {
501
+ // Full LINES that look like a declaration of `id` in the file.
502
+ const lineRe = new RegExp(
503
+ `^[ \\t]*(?:export\\s+)?(?:const|let|var|function|async\\s+function|private|public|protected|readonly)\\b[^\\n]*(?:^|[^.\\w$])${esc(id)}(?![\\w$])[^\\n]*$`,
504
+ 'gm',
505
+ );
506
+ const decls = [];
507
+ while ((m = lineRe.exec(content))) decls.push(m[0]);
508
+ // Exactly ONE declaration line, and that line must be unique as a substring
509
+ // (so indexOf-based insertion is deterministic and idempotent).
510
+ if (decls.length === 1 && countOccurrences(content, decls[0]) === 1) {
511
+ return decls[0];
512
+ }
513
+ }
514
+ return null;
515
+ }
516
+
517
+ /**
518
+ * True when an anchor that appears several times is HARMLESS: every occurrence
519
+ * resolves to the same actual insertion point (the nearest JSX `return`), so
520
+ * the ambiguity cannot change where the registration lands.
521
+ */
522
+ function ambiguousAnchorSameInsertion(content, anchor) {
523
+ const points = new Set();
524
+ let idx = content.indexOf(anchor);
525
+ while (idx !== -1) {
526
+ points.add(nearestJsxReturn(content, idx));
527
+ if (points.size > 1) return false;
528
+ idx = content.indexOf(anchor, idx + anchor.length);
529
+ }
530
+ // A single resolved point that exists (-1 = no JSX return found → not safe).
531
+ return points.size === 1 && !points.has(-1);
532
+ }
533
+
470
534
  /**
471
535
  * Verify the AI's BRIDGE proposal for ONE action. A bridge edits the user's own
472
536
  * component (under consent) to register the live, in-scope function/instance so
473
537
  * the standalone registry can reach component-local state. The verifier confirms
474
538
  * the edit can be applied SAFELY and that nothing is fabricated:
475
- * · the component file exists and the anchor line is present EXACTLY once
476
- * (so the insertion point is unambiguous and idempotent),
539
+ * · the component file exists and the anchor resolves to ONE unambiguous
540
+ * insertion point — when the AI's anchor is missing/ambiguous, a mechanical
541
+ * anchor is derived from the declaration of the invoked symbol (see
542
+ * deriveBridgeAnchor) instead of giving up,
477
543
  * · every requiredSymbol exists in the code,
478
544
  * · `invoke` does not create a detached `new Service()` instance,
479
545
  * · every identifier `invoke` reads is `args`/`this`, a safe global, an import
@@ -483,7 +549,7 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
483
549
  function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
484
550
  const b = aiW.bridge || {};
485
551
  const file = normRel(b.file);
486
- const anchor = String(b.anchor || '').trim();
552
+ let anchor = String(b.anchor || '').trim();
487
553
  const invoke = String(b.invoke || '').trim();
488
554
  const why = aiW.bridgeReason ? ` (${aiW.bridgeReason})` : '';
489
555
 
@@ -491,11 +557,29 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
491
557
  const content = readFileSafe(appRoot, file);
492
558
  if (content == null) return { reason: `could not read the component '${file}' to apply the bridge` };
493
559
  if (!invoke) return { reason: `the AI did not provide the bridge 'invoke' expression for '${file}'` };
494
- if (!anchor) return { reason: `the AI did not provide an insertion anchor in '${file}'` };
495
560
 
496
- const occ = countOccurrences(content, anchor);
497
- if (occ === 0) return { reason: `no safe insertion point was found in '${file}' — wire this action manually` };
498
- if (occ > 1) return { reason: `the insertion point in '${file}' is ambiguous — wire this action manually` };
561
+ // Anchor resolution ladder: exact-unique AI anchor → harmless-ambiguous AI
562
+ // anchor mechanically derived declaration anchor review. The old behavior
563
+ // (reject on 0/>1 occurrences) left real, wireable actions "manual" whenever
564
+ // the AI picked a repeated line in a big file.
565
+ let anchorDerived = false;
566
+ const occ = anchor ? countOccurrences(content, anchor) : 0;
567
+ if (occ !== 1) {
568
+ const scope = b.scope === 'class-field' ? 'class-field' : 'statement';
569
+ if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
570
+ // Keep the AI anchor: every occurrence lands on the same insertion point.
571
+ } else {
572
+ const derived = deriveBridgeAnchor(content, invoke);
573
+ if (derived) {
574
+ anchor = derived;
575
+ anchorDerived = true;
576
+ } else if (!anchor || occ === 0) {
577
+ return { reason: `no safe insertion point was found in '${file}' — wire this action manually` };
578
+ } else {
579
+ return { reason: `the insertion point in '${file}' is ambiguous — wire this action manually` };
580
+ }
581
+ }
582
+ }
499
583
 
500
584
  // requiredSymbols must really exist.
501
585
  const required = Array.isArray(aiW.requiredSymbols) ? aiW.requiredSymbols : [];
@@ -1854,6 +1938,8 @@ module.exports = {
1854
1938
  findExportedFunction,
1855
1939
  findStoreMethod,
1856
1940
  mapParams,
1941
+ deriveBridgeAnchor,
1942
+ ambiguousAnchorSameInsertion,
1857
1943
  // shared with wireSession (same verification + injection machinery)
1858
1944
  fileHasSymbol,
1859
1945
  freeIdentifiers,