@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.
@@ -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.