@fiodos/cli 0.1.25 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/aiAnalyze.js +6 -4
- package/src/changeRegistry.js +55 -1
- package/src/collect.js +16 -2
- package/src/index.js +404 -8
- package/src/postWireTest.js +27 -6
- package/src/shopifyTheme.js +339 -0
- package/src/verify.js +124 -1
- package/src/verifyWire.js +10 -0
- package/src/wireHandlers.js +216 -28
- package/src/wireScreen.js +562 -0
- package/src/wireSession.js +147 -11
- package/src/wireWeb.js +115 -2
- package/src/wireWebMount.js +98 -14
|
@@ -0,0 +1,339 @@
|
|
|
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 into ONE liquid layout file, 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
|
+
* 'not-found', file, reason?, refreshed? }.
|
|
248
|
+
*/
|
|
249
|
+
function injectSnippetInLayout(file, relFile, snippet, { noWire = false } = {}) {
|
|
250
|
+
if (!fs.existsSync(file)) {
|
|
251
|
+
return { status: 'not-found', file: relFile };
|
|
252
|
+
}
|
|
253
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
254
|
+
|
|
255
|
+
const markerRe = new RegExp(`${escapeRe(ORB_START)}[\\s\\S]*?${escapeRe(ORB_END)}`);
|
|
256
|
+
if (markerRe.test(source)) {
|
|
257
|
+
const existing = source.match(markerRe)[0];
|
|
258
|
+
if (existing === snippet) return { status: 'already', file: relFile };
|
|
259
|
+
if (noWire) return { status: 'skipped', file: relFile, reason: '--no-wire' };
|
|
260
|
+
fs.writeFileSync(file, source.replace(markerRe, snippet));
|
|
261
|
+
return { status: 'added', file: relFile, refreshed: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (noWire) return { status: 'skipped', file: relFile, reason: '--no-wire' };
|
|
265
|
+
const bodyClose = source.lastIndexOf('</body>');
|
|
266
|
+
const next = bodyClose === -1
|
|
267
|
+
? `${source}\n${snippet}\n`
|
|
268
|
+
: `${source.slice(0, bodyClose)}${snippet}\n ${source.slice(bodyClose)}`;
|
|
269
|
+
fs.writeFileSync(file, next);
|
|
270
|
+
return { status: 'added', file: relFile };
|
|
271
|
+
}
|
|
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
|
+
|
|
308
|
+
function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
|
|
309
|
+
const { blue = '', cyan = '', reset = '' } = colors;
|
|
310
|
+
const say = (line) => console.error(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
|
|
311
|
+
if (result.status === 'added') {
|
|
312
|
+
say(`orb embedded in ${result.file}${result.refreshed ? ' (snippet refreshed)' : ''} — commit & push to deploy via Shopify's GitHub sync`);
|
|
313
|
+
} else if (result.status === 'already') {
|
|
314
|
+
say(`orb already embedded in ${result.file}`);
|
|
315
|
+
} else if (result.status === 'failed') {
|
|
316
|
+
say(`could not embed the orb: ${result.reason}`);
|
|
317
|
+
} else if (!quiet) {
|
|
318
|
+
say(`orb embed skipped (${result.reason || result.status})`);
|
|
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
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
isShopifyThemeRoot,
|
|
331
|
+
readThemeMeta,
|
|
332
|
+
fetchStorefrontSnapshot,
|
|
333
|
+
buildShopifySnippet,
|
|
334
|
+
wireShopifyTheme,
|
|
335
|
+
reportShopifyWire,
|
|
336
|
+
normalizeStoreDomain,
|
|
337
|
+
FIODOS_EMBED_CDN_URL,
|
|
338
|
+
SNAPSHOT_DIR,
|
|
339
|
+
};
|
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 };
|
package/src/verifyWire.js
CHANGED
|
@@ -52,8 +52,16 @@ function normErr(line) {
|
|
|
52
52
|
* really broken), as opposed to a style violation? These mean the safety net
|
|
53
53
|
* MUST act: the action does not actually work.
|
|
54
54
|
*/
|
|
55
|
+
// "Declared but never used" (TS6133/6192/6196/6198/6199/6205) is a STRICTNESS
|
|
56
|
+
// gate (noUnusedLocals), never broken code. It also appears as an ARTIFACT of
|
|
57
|
+
// per-action isolated verification: wiring one bridge action alone leaves the
|
|
58
|
+
// sibling symbols of the same destructuring temporarily unused, which used to
|
|
59
|
+
// fail every action in that file (the navigate_section/logout production bug).
|
|
60
|
+
const UNUSED_CODE_TS_RE = /error\s+TS6(?:133|192|196|198|199|205)\b/i;
|
|
61
|
+
|
|
55
62
|
function isRealCompileError(line) {
|
|
56
63
|
const l = String(line);
|
|
64
|
+
if (UNUSED_CODE_TS_RE.test(l)) return false;
|
|
57
65
|
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
66
|
}
|
|
59
67
|
|
|
@@ -66,6 +74,8 @@ function isRealCompileError(line) {
|
|
|
66
74
|
function isLintError(line) {
|
|
67
75
|
const l = String(line);
|
|
68
76
|
if (isRealCompileError(l)) return false;
|
|
77
|
+
// Unused-symbol strictness (see UNUSED_CODE_TS_RE) is style, not brokenness.
|
|
78
|
+
if (UNUSED_CODE_TS_RE.test(l)) return true;
|
|
69
79
|
// Reference to a known ESLint rule namespace / a bare `no-*` rule.
|
|
70
80
|
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
81
|
// ESLint compact format "12:5 Error message rule" without a TS error code.
|