@fiodos/cli 0.1.33 → 0.1.35

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.33",
3
+ "version": "0.1.35",
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');
@@ -220,11 +220,46 @@ function wiringSpinnerLabel(platform) {
220
220
  return `Wiring actions to your ${surfaceNoun(platform)}`;
221
221
  }
222
222
 
223
- /** Branded terminal spinner (Fiodos orb + colors when stderr is a TTY). */
223
+ /**
224
+ * Fit a colored line into ONE terminal row. The in-place refresh
225
+ * (`\r` + erase-line) only works if the rendered line never wraps: on a
226
+ * narrow terminal (IDE panes) a wrapped spinner line leaves one orphan row
227
+ * BEHIND per tick — hundreds of stacked "Analyzing your project…" lines
228
+ * instead of a single live counter. ANSI escape sequences are copied without
229
+ * counting toward the visible width, so colors survive the truncation.
230
+ */
231
+ function fitLineToWidth(line, columns) {
232
+ const cols = Number(columns);
233
+ if (!Number.isFinite(cols) || cols <= 0) return line;
234
+ const max = cols - 1; // last cell stays free: writing it can auto-wrap
235
+ let out = '';
236
+ let visible = 0;
237
+ for (let i = 0; i < line.length; ) {
238
+ if (line[i] === '\x1b') {
239
+ const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
240
+ if (m) {
241
+ out += m[0];
242
+ i += m[0].length;
243
+ continue;
244
+ }
245
+ }
246
+ if (visible >= max) break;
247
+ out += line[i];
248
+ visible += 1;
249
+ i += 1;
250
+ }
251
+ // Close any open color so the truncated tail never bleeds styles.
252
+ return visible >= max ? `${out}\x1b[0m` : out;
253
+ }
254
+
255
+ /** Branded terminal spinner (Fiodos orb + colors when stderr is a TTY).
256
+ * Without a TTY (piped/CI output) there is no in-place line refresh, so the
257
+ * animation would stack one line per tick — print the label ONCE instead. */
224
258
  async function withSpinner(label, work) {
225
259
  const orbFrames = ['◴', '◷', '◶', '◵'];
226
260
  const brailleFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
227
261
  const { blue, cyan, dim, reset } = fyodosTermColors();
262
+ const isTTY = Boolean(process.stderr.isTTY);
228
263
  let currentLabel = label;
229
264
  let frame = 0;
230
265
  let id = null;
@@ -236,13 +271,18 @@ async function withSpinner(label, work) {
236
271
  const tail = brailleFrames[frame % brailleFrames.length];
237
272
  const line =
238
273
  `${cyan}${orb}${reset} ${blue}Fiodos${reset} ${dim}${tail}${reset} ${currentLabel}… ${dim}${sec}s${reset}`;
239
- process.stderr.write(`\r\x1b[K${line}`);
274
+ process.stderr.write(`\r\x1b[K${fitLineToWidth(line, process.stderr.columns)}`);
240
275
  frame += 1;
241
276
  };
242
277
 
243
278
  const clearLine = () => process.stderr.write('\r\x1b[K');
244
279
 
245
280
  const startSpinner = () => {
281
+ if (!isTTY) {
282
+ // One static line; no timer (nothing can be refreshed without a TTY).
283
+ process.stderr.write(`${cyan}◉${reset} ${blue}Fiodos${reset} ${currentLabel}…\n`);
284
+ return;
285
+ }
246
286
  render();
247
287
  id = setInterval(render, 140);
248
288
  };
@@ -256,11 +296,14 @@ async function withSpinner(label, work) {
256
296
  };
257
297
 
258
298
  const resume = () => {
259
- if (!id) startSpinner();
299
+ if (!id && isTTY) startSpinner();
260
300
  };
261
301
 
262
302
  const setLabel = (next) => {
263
303
  currentLabel = next;
304
+ // Without a TTY the label is only ever printed once — announce the change
305
+ // on its own line so progress is still visible in piped/CI output.
306
+ if (!isTTY) process.stderr.write(`${cyan}◉${reset} ${blue}Fiodos${reset} ${next}…\n`);
264
307
  };
265
308
 
266
309
  startSpinner();
@@ -539,6 +582,18 @@ async function main() {
539
582
  `actions=${(proposed.actions || []).length} in ${(elapsedMs / 1000).toFixed(1)}s`);
540
583
  logDev(`[ai] tokens in=${usage.prompt_tokens} out=${usage.completion_tokens} cost=$${costUSD.toFixed(4)}`);
541
584
 
585
+ // ── 3b. Shopify platform guarantee (BEFORE verification) ────────────────
586
+ // The five universal commerce actions carry platform-constant execution
587
+ // templates; never trust the AI to have reproduced them. Enforcing them
588
+ // here means verification's structural check always passes for the
589
+ // guaranteed set and a Shopify manifest can never publish inert.
590
+ if (platform === 'shopify') {
591
+ const repairedIntents = enforceShopifyGuaranteedExecutions(proposed);
592
+ if (repairedIntents.length) {
593
+ logDev(`[shopify] canonical execution enforced on: ${repairedIntents.join(', ')}`);
594
+ }
595
+ }
596
+
542
597
  // ── 4. Mechanical verification of the AI's claims ───────────────────────
543
598
  // Actions are always verified against real code. Routes are verified only
544
599
  // 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) {
@@ -358,6 +461,7 @@ function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
358
461
  }
359
462
 
360
463
  module.exports = {
464
+ enforceShopifyGuaranteedExecutions,
361
465
  isShopifyThemeRoot,
362
466
  readThemeMeta,
363
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,