@fiodos/cli 0.1.32 → 0.1.34

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.32",
3
+ "version": "0.1.34",
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/index.js CHANGED
@@ -102,7 +102,7 @@ const {
102
102
  const { wireReactNativeOrb, reportReactNativeWire, connectRnSession } = require('./wireReactNative');
103
103
  const {
104
104
  isShopifyThemeRoot, fetchStorefrontSnapshot, wireShopifyTheme, reportShopifyWire,
105
- normalizeStoreDomain,
105
+ normalizeStoreDomain, enforceShopifyGuaranteedExecutions,
106
106
  } = require('./shopifyTheme');
107
107
  const { wireFlutterOrb, writeFlutterEnv, reportFlutterWire } = require('./wireFlutter');
108
108
  const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
@@ -539,6 +539,18 @@ async function main() {
539
539
  `actions=${(proposed.actions || []).length} in ${(elapsedMs / 1000).toFixed(1)}s`);
540
540
  logDev(`[ai] tokens in=${usage.prompt_tokens} out=${usage.completion_tokens} cost=$${costUSD.toFixed(4)}`);
541
541
 
542
+ // ── 3b. Shopify platform guarantee (BEFORE verification) ────────────────
543
+ // The five universal commerce actions carry platform-constant execution
544
+ // templates; never trust the AI to have reproduced them. Enforcing them
545
+ // here means verification's structural check always passes for the
546
+ // guaranteed set and a Shopify manifest can never publish inert.
547
+ if (platform === 'shopify') {
548
+ const repairedIntents = enforceShopifyGuaranteedExecutions(proposed);
549
+ if (repairedIntents.length) {
550
+ logDev(`[shopify] canonical execution enforced on: ${repairedIntents.join(', ')}`);
551
+ }
552
+ }
553
+
542
554
  // ── 4. Mechanical verification of the AI's claims ───────────────────────
543
555
  // Actions are always verified against real code. Routes are verified only
544
556
  // when there is filesystem ground truth (mobile); for web they are kept as
@@ -35,6 +35,109 @@ const ORB_END = '<!-- FYODOS:ORB:END -->';
35
35
  /** Repo-relative folder of the synthetic storefront snapshot files. */
36
36
  const SNAPSHOT_DIR = '_fyodos/storefront';
37
37
 
38
+ /**
39
+ * Canonical mechanics of the five guaranteed Shopify commerce actions.
40
+ *
41
+ * These execution templates are PLATFORM CONSTANTS (identical on every
42
+ * Shopify store), yet the analysis prompt asks the AI to reproduce them
43
+ * verbatim — and models sometimes omit or mangle the `execution` block. A
44
+ * manifest published without it is silently dead: the embed registers zero
45
+ * handlers and the orb promises cart actions it can never run (observed in
46
+ * production). `enforceShopifyGuaranteedExecutions` overwrites the MECHANICS
47
+ * (execution/parameters) of any action matching intent+handler while leaving
48
+ * the AI's personalized prose (labels, descriptions, examples) untouched.
49
+ * The backend applies the same guarantee at publish/serve time
50
+ * (backend/clients/shopify_guarantees.py) — keep both in sync.
51
+ */
52
+ const SHOPIFY_GUARANTEED_ACTIONS = {
53
+ search_products: {
54
+ handler: 'shopifySearchProducts',
55
+ parameters: { query: { type: 'string', required: true } },
56
+ execution: {
57
+ kind: 'http',
58
+ method: 'GET',
59
+ url: '{baseUrl}/search/suggest.json?q={query}&resources[type]=product&resources[limit]=8',
60
+ },
61
+ },
62
+ get_product_details: {
63
+ handler: 'shopifyGetProductDetails',
64
+ parameters: { handle: { type: 'string', required: true } },
65
+ execution: { kind: 'http', method: 'GET', url: '{baseUrl}/products/{handle}.js' },
66
+ },
67
+ add_to_cart: {
68
+ handler: 'shopifyAddToCart',
69
+ parameters: {
70
+ variantId: { type: 'number', required: true },
71
+ quantity: { type: 'number', required: true },
72
+ },
73
+ execution: {
74
+ kind: 'http',
75
+ method: 'POST',
76
+ url: '{baseUrl}/cart/add.js',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: { id: '{variantId}', quantity: '{quantity}' },
79
+ },
80
+ },
81
+ get_cart: {
82
+ handler: 'shopifyGetCart',
83
+ parameters: {},
84
+ execution: { kind: 'http', method: 'GET', url: '{baseUrl}/cart.js' },
85
+ },
86
+ // variantId-based on purpose (cart/change.js accepts a variant id as `id`):
87
+ // the theme snippet's cart screen context hands the agent variantIds, so
88
+ // the action contract must accept exactly what the agent can see.
89
+ update_cart_item: {
90
+ handler: 'shopifyUpdateCartItem',
91
+ parameters: {
92
+ variantId: { type: 'number', required: true },
93
+ quantity: { type: 'number', required: true },
94
+ },
95
+ execution: {
96
+ kind: 'http',
97
+ method: 'POST',
98
+ url: '{baseUrl}/cart/change.js',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: { id: '{variantId}', quantity: '{quantity}' },
101
+ },
102
+ },
103
+ };
104
+
105
+ /**
106
+ * Enforce the canonical execution/parameters on every guaranteed commerce
107
+ * action of a Shopify manifest, in place. Prose fields are never touched;
108
+ * per-parameter descriptions written by the AI survive when the parameter
109
+ * survives. Returns the intents that were repaired (for logging).
110
+ */
111
+ function enforceShopifyGuaranteedExecutions(manifest) {
112
+ const repaired = [];
113
+ if (!manifest || !Array.isArray(manifest.actions)) return repaired;
114
+ for (const action of manifest.actions) {
115
+ if (!action || typeof action !== 'object' || action.kind === 'link') continue;
116
+ const spec = SHOPIFY_GUARANTEED_ACTIONS[String(action.intent || '').trim()];
117
+ if (!spec || String(action.handler || '').trim() !== spec.handler) continue;
118
+
119
+ const params = {};
120
+ const prior = (action.parameters && typeof action.parameters === 'object') ? action.parameters : {};
121
+ for (const [name, shape] of Object.entries(spec.parameters)) {
122
+ params[name] = { ...shape };
123
+ const priorShape = prior[name];
124
+ if (priorShape && typeof priorShape.description === 'string') {
125
+ params[name].description = priorShape.description;
126
+ }
127
+ }
128
+
129
+ const already =
130
+ JSON.stringify(action.execution || null) === JSON.stringify(spec.execution) &&
131
+ JSON.stringify(Object.keys(prior).sort()) === JSON.stringify(Object.keys(spec.parameters).sort());
132
+ if (already) continue;
133
+
134
+ action.execution = JSON.parse(JSON.stringify(spec.execution));
135
+ action.parameters = params;
136
+ repaired.push(action.intent);
137
+ }
138
+ return repaired;
139
+ }
140
+
38
141
  /** A Shopify theme repo is unmistakable: layout/theme.liquid is the layout
39
142
  * every Online Store 2.0 theme must have, plus templates/ or sections/. */
40
143
  function isShopifyThemeRoot(appRoot) {
@@ -108,6 +211,10 @@ function universalSurfaceDoc(storeDomain) {
108
211
  universalRoutes: ['/', '/collections/all', '/search', '/cart'],
109
212
  universalActions: [
110
213
  { intent: 'search_products', handler: 'shopifySearchProducts', api: '{baseUrl}/search/suggest.json' },
214
+ // The search→cart bridge: suggest.json returns product ids/handles but
215
+ // NEVER variant ids, and cart/add.js only accepts variant ids. This
216
+ // endpoint (universal on every store) turns a handle into its variants.
217
+ { intent: 'get_product_details', handler: 'shopifyGetProductDetails', api: '{baseUrl}/products/{handle}.js' },
111
218
  { intent: 'add_to_cart', handler: 'shopifyAddToCart', api: '{baseUrl}/cart/add.js' },
112
219
  { intent: 'get_cart', handler: 'shopifyGetCart', api: '{baseUrl}/cart.js' },
113
220
  { intent: 'update_cart_item', handler: 'shopifyUpdateCartItem', api: '{baseUrl}/cart/change.js' },
@@ -354,6 +461,7 @@ function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
354
461
  }
355
462
 
356
463
  module.exports = {
464
+ enforceShopifyGuaranteedExecutions,
357
465
  isShopifyThemeRoot,
358
466
  readThemeMeta,
359
467
  fetchStorefrontSnapshot,
package/src/verify.js CHANGED
@@ -298,6 +298,18 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
298
298
  });
299
299
  continue;
300
300
  }
301
+ // Shopify function action WITHOUT an execution block: nothing can ever run
302
+ // it (storefronts have no wireable code; the embed builds handlers ONLY
303
+ // from execution specs). Publishing it would make the orb promise an
304
+ // action that silently dies — drop it honestly. The guaranteed commerce
305
+ // set never lands here: its canonical execution is enforced pre-verify.
306
+ if (isShopify && action.kind !== 'link') {
307
+ provenance.actions.push({
308
+ intent: action.intent, included: false,
309
+ reason: 'shopify function action without a declarative execution block — nothing could ever run it — DROPPED',
310
+ });
311
+ continue;
312
+ }
301
313
  if (isShopify && action.kind === 'link' && String(action.navTarget || '').trim() === '/checkout') {
302
314
  action.handler = '';
303
315
  actions.push(action);
@@ -445,6 +445,41 @@ function relImport(fromDirRel, toFileRel) {
445
445
  return rel;
446
446
  }
447
447
 
448
+ /**
449
+ * PARAMETER CONTRACT check shared by both wiring strategies: every
450
+ * `<receiver>.<name>` / `<receiver>['<name>']` the expression reads must be a
451
+ * parameter the manifest declares for this action. The executor builds that
452
+ * object exclusively from the manifest parameters, so any other name is
453
+ * ALWAYS undefined at runtime — the action then "succeeds" while silently
454
+ * doing the wrong thing (e.g. an invoke reading `args.voiceMode` when the
455
+ * manifest declares 'mode': every call fell back to a default value). It
456
+ * compiles clean, so only this check can catch it.
457
+ */
458
+ function checkParamContract(expr, receiver, manifestParams) {
459
+ const declared = new Set((manifestParams || []).map((p) => p.name));
460
+ const refs = new Set();
461
+ let m;
462
+ const dotRe = new RegExp(`\\b${receiver}\\.([A-Za-z_$][\\w$]*)`, 'g');
463
+ while ((m = dotRe.exec(expr))) refs.add(m[1]);
464
+ const bracketRe = new RegExp(`\\b${receiver}\\[\\s*['"]([^'"]+)['"]\\s*\\]`, 'g');
465
+ while ((m = bracketRe.exec(expr))) refs.add(m[1]);
466
+ for (const ref of refs) {
467
+ if (!declared.has(ref)) {
468
+ const hint = declared.size
469
+ ? `the manifest declares: ${[...declared].map((n) => `'${n}'`).join(', ')}`
470
+ : 'the manifest declares NO parameters for this action';
471
+ return {
472
+ ok: false,
473
+ reason:
474
+ `the wiring reads '${receiver}.${ref}', but ${hint} — '${receiver}' only ever carries the ` +
475
+ 'declared manifest parameters, so that value would always be undefined at runtime. ' +
476
+ 'Use the declared parameter name(s) exactly.',
477
+ };
478
+ }
479
+ }
480
+ return { ok: true };
481
+ }
482
+
448
483
  /**
449
484
  * Turn the AI's proposed wiring for ONE action into a verified `auto` entry, or
450
485
  * return { reason } when the proposal cannot be safely trusted. This is the
@@ -518,6 +553,14 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
518
553
  };
519
554
  }
520
555
 
556
+ // 5) PARAMETER CONTRACT (same guarantee as the bridge strategy): every
557
+ // `params.<name>` / `params['<name>']` the call reads must be a parameter
558
+ // the manifest declares for this action — anything else is ALWAYS
559
+ // undefined at runtime and produces a silent wrong-value execution that
560
+ // compiles clean and "succeeds".
561
+ const paramContract = checkParamContract(call, 'params', manifestParams);
562
+ if (!paramContract.ok) return { reason: paramContract.reason };
563
+
521
564
  return {
522
565
  ...base,
523
566
  confidence: 'auto',
@@ -703,6 +746,11 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
703
746
  };
704
747
  }
705
748
 
749
+ // PARAMETER CONTRACT: every `args.<name>` the invoke reads must be a
750
+ // parameter the manifest declares for THIS action (see checkParamContract).
751
+ const paramContract = checkParamContract(invoke, 'args', base.manifestParams);
752
+ if (!paramContract.ok) return { reason: paramContract.reason };
753
+
706
754
  // Verify extra imports the invoke needs.
707
755
  const imports = [];
708
756
  const importedNames = new Set();
@@ -1345,6 +1393,44 @@ function stripFiodosBlocks(content) {
1345
1393
  );
1346
1394
  }
1347
1395
 
1396
+ /**
1397
+ * Sweep GENERATED bridge blocks left behind in files this run does NOT wire.
1398
+ *
1399
+ * applyComponentEdits strips old blocks only in the files it edits THIS run,
1400
+ * so when a re-analysis moves an action's wiring to a different component the
1401
+ * previous component keeps a stale registerFiodosBridge() block: a duplicate
1402
+ * registration for the same key whose argument contract may no longer match —
1403
+ * whichever component mounts last silently wins (the select_voice_mode
1404
+ * production bug: two components registered the key, one reading args.mode
1405
+ * and the other args.voiceMode, and the action "ran" without doing anything).
1406
+ *
1407
+ * Only blocks carrying the generated header are removed; developer-authored
1408
+ * FYODOS:BRIDGE blocks are preserved verbatim (same rule as
1409
+ * stripFiodosBlocks). Returns backups compatible with revertAll so a combined
1410
+ * build regression restores the swept files too.
1411
+ */
1412
+ function sweepStaleGeneratedBlocks(appRoot, files, editedFiles) {
1413
+ const edited = new Set((editedFiles || []).map((f) => normRel(f)));
1414
+ const backups = [];
1415
+ for (const f of files || []) {
1416
+ const rel = normRel(f && f.rel);
1417
+ if (!rel || edited.has(rel)) continue;
1418
+ const abs = path.join(appRoot, rel);
1419
+ let original;
1420
+ try {
1421
+ original = fs.readFileSync(abs, 'utf8');
1422
+ } catch {
1423
+ continue;
1424
+ }
1425
+ if (!original.includes(EDIT_START_PREFIX)) continue;
1426
+ const swept = stripFiodosBlocks(original);
1427
+ if (swept === original) continue;
1428
+ backups.push({ file: rel, abs, original });
1429
+ fs.writeFileSync(abs, swept);
1430
+ }
1431
+ return backups;
1432
+ }
1433
+
1348
1434
  /** Insert an import block right after the file's last top-level import statement.
1349
1435
  * Matches the WHOLE statement through its module string — a per-line regex put
1350
1436
  * the block INSIDE multi-line imports (`import {\n a,\n} from 'x';`), corrupting
@@ -1926,6 +2012,7 @@ async function wireHandlers(appRoot, opts = {}) {
1926
2012
  }
1927
2013
  fs.writeFileSync(registryAbs, buildRegistryModule(finalPlan, { ts, esm }));
1928
2014
  ({ backups } = applyComponentEdits(appRoot, finalEdits));
2015
+ backups.push(...sweepStaleGeneratedBlocks(appRoot, files, finalEdits.map((e) => e.file)));
1929
2016
  } catch (err) {
1930
2017
  revertAll(backups, generatedFiles);
1931
2018
  return { status: 'failed', docPath: docAbs, error: err, plan: finalPlan, verification: loop.attemptsLog };
@@ -1972,6 +2059,9 @@ async function wireHandlers(appRoot, opts = {}) {
1972
2059
  }
1973
2060
  fs.writeFileSync(registryAbs, buildRegistryModule(survivorPlan, { ts, esm }));
1974
2061
  ({ backups: survivorBackups } = applyComponentEdits(appRoot, survivorEdits));
2062
+ survivorBackups.push(
2063
+ ...sweepStaleGeneratedBlocks(appRoot, files, survivorEdits.map((e) => e.file)),
2064
+ );
1975
2065
  } catch (err) {
1976
2066
  revertAll(survivorBackups, survivorGenerated);
1977
2067
  return { status: 'failed', docPath: docAbs, error: err, plan: finalPlan, verification: loop.attemptsLog };
@@ -2041,6 +2131,7 @@ async function wireHandlers(appRoot, opts = {}) {
2041
2131
  throw new Error('post-write verification failed');
2042
2132
  }
2043
2133
  ({ backups } = applyComponentEdits(appRoot, componentEdits));
2134
+ backups.push(...sweepStaleGeneratedBlocks(appRoot, files, componentEdits.map((e) => e.file)));
2044
2135
  } catch (err) {
2045
2136
  revertAll(backups, generatedFiles);
2046
2137
  return { status: 'failed', docPath: docAbs, error: err, plan };
@@ -2138,6 +2229,8 @@ module.exports = {
2138
2229
  planComponentEdits,
2139
2230
  applyComponentEdits,
2140
2231
  stripFiodosBlocks,
2232
+ sweepStaleGeneratedBlocks,
2233
+ checkParamContract,
2141
2234
  revertAll,
2142
2235
  runAutoCorrectionLoop,
2143
2236
  writeIsolated,