@fiodos/cli 0.1.10 → 0.1.11

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.10",
3
+ "version": "0.1.11",
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": {
@@ -19,7 +19,7 @@
19
19
  "node": ">=18"
20
20
  },
21
21
  "dependencies": {
22
- "@fiodos/core": "^0.1.0",
22
+ "@fiodos/core": "^0.1.1",
23
23
  "esbuild": "^0.28.1",
24
24
  "jsdom": "^24.0.0",
25
25
  "typescript": "^5.6.0"
package/src/aiAnalyze.js CHANGED
@@ -66,7 +66,9 @@ AppManifest (all fields required unless noted):
66
66
  "actions": [{
67
67
  "intent": string (snake_case, UNIQUE across routes AND actions),
68
68
  "label": string, "category": string,
69
- "handler": string (name of the REAL function in the app code that implements it),
69
+ "kind": "function" | "link" (OPTIONAL, default "function". Use "link" for navigation/link actions: open an external/download URL, jump to a page section, open an email/phone/social link. Web landing pages are mostly these.),
70
+ "handler": string (for kind "function": name of the REAL function in the app code that implements it. For kind "link": OMIT or empty — link actions have no handler.),
71
+ "navTarget": string (REQUIRED for kind "link": the EXACT href/URL/anchor string as written in the code — e.g. "https://apps.apple.com/...", "#contact", "mailto:hi@x.com". Must appear verbatim in the evidence file.),
70
72
  "description": string (what the action does, in clear plain language),
71
73
  "preconditions": string (what must be true to run it, in words — e.g. "the cart must have at least one item"; empty string if none),
72
74
  "effect": string (what happens AFTER it runs — the result/outcome the user perceives),
@@ -80,7 +82,7 @@ AppManifest (all fields required unless noted):
80
82
  "policies": {"requireConfirmationForDestructive": true, "allowedWithoutAuth": string[], "contextRetentionTurns": 5}
81
83
  }
82
84
  Validation rules: no duplicate intents anywhere; every route/action needs non-empty examples;
83
- actions need non-empty handler; requireConfirmation implies confirmationMessageTemplate.
85
+ "function" actions need non-empty handler; "link" actions need non-empty navTarget (no handler); requireConfirmation implies confirmationMessageTemplate.
84
86
  The description/preconditions/effect/relatedIntents (actions), description (routes) and appType/appFlow
85
87
  fields are the app's "manual": they go verbatim into the agent's prompt so it understands the app.
86
88
  Base them on the REAL code you read; be precise and concise, never padding.`;
@@ -98,7 +100,10 @@ const PLATFORM_GUIDES = {
98
100
  routeHintLine:
99
101
  'No reliable static route list is available for web apps, so infer routes/views from the router setup, page/route components, and navigation calls (react-router <Route>, Next.js pages, navigate()/<Link>). Routes you propose are NOT statically verified, so only include ones you can actually see in the code below.',
100
102
  actionHintLine:
101
- 'Actions are functions wired to UI IN THE SOURCE FILES PROVIDED BELOW: handlers inside components, store/context actions (Zustand, Redux, React Context), or service functions. Set "handler" to that real function name exactly. CRITICAL: the manifest is for THIS web deployment only do NOT include routes or actions from mobile/native code that is not in the file list (even if you infer it exists elsewhere in the monorepo).',
103
+ 'Web actions come in TWO kinds, BOTH first-classdetect both:\n' +
104
+ ' (1) FUNCTION actions (kind "function"): functions wired to UI — handlers inside components, store/context actions (Zustand, Redux, React Context), or service functions. Set "handler" to that real function name exactly. INCLUDE common web product actions you might be tempted to skip: switching language/locale (e.g. setLang/setLocale/i18n.changeLanguage), toggling theme, submitting a contact/newsletter/search form. (Still skip pure micro-plumbing like opening a dropdown or controlled input onChange.)\n' +
105
+ ' (2) LINK actions (kind "link"): the core actions of marketing/landing pages, which are NAVIGATION not function calls — download/get-the-app buttons (App Store / Google Play / direct download), "contact us" / email / phone links, social links, and in-page jumps to a section (anchor hrefs like #pricing). For these set kind "link" and navTarget to the EXACT href string in the code; do NOT set handler. A list of detected link elements on the product-surface pages is provided below — turn the meaningful ones into link actions (group obvious duplicates, e.g. several App Store buttons → one "download on the App Store").\n' +
106
+ 'CRITICAL: the manifest is for THIS web deployment only — do NOT include routes or actions from mobile/native code that is not in the file list (even if you infer it exists elsewhere in the monorepo).',
102
107
  },
103
108
  mobile: {
104
109
  appKindLabel: 'a mobile app (Expo / React Native)',
@@ -138,7 +143,9 @@ function buildSystemPrompt(platform) {
138
143
  const { appKindLabel, screenWord, routeHintLine, actionHintLine } = guide;
139
144
  return `You analyze the FULL source code of ${appKindLabel} and produce the manifest of an in-app voice agent (Fiodos AppManifest).
140
145
 
141
- ROUTES: every ${screenWord} the user can navigate to. ${routeHintLine} Skip auth screens (sign-in/sign-up) and dynamic [param] routes. Always include a BACK route ("route": "BACK").
146
+ ${platform === 'web' ? `PRODUCT SURFACE FOCUS (IMPORTANT): some files below are marked [PRODUCT SURFACE] and others [SECONDARY]. The PRODUCT SURFACE is what is reachable by navigation from the deployed home page — it is what this web product actually IS. Concentrate your routes and actions there. [SECONDARY] files exist in the repo but are NOT reachable from the home (e.g. a separate app mirrored inside the same codebase, dead/work-in-progress screens); model something from them ONLY if it is unmistakably part of this same product. When in doubt, prefer the product surface. This is how you avoid drowning a landing's real actions under a secondary app's internals.
147
+
148
+ ` : ''}ROUTES: every ${screenWord} the user can navigate to. ${routeHintLine} Skip auth screens (sign-in/sign-up) and dynamic [param] routes. Always include a BACK route ("route": "BACK").
142
149
 
143
150
  ACTIONS: things a user could trigger by voice. ${actionHintLine} CRITICAL RULES:
144
151
  - Every action MUST be backed by a REAL function you can see in the provided code. Set "handler" to that real function's name exactly as written in the code.
@@ -232,8 +239,23 @@ For EVERY action, also return a "wiring" entry describing how a separate module
232
239
  - The confirmation flow is enforced by the engine BEFORE your handler runs, so never add your own confirmation logic; just perform the action.`;
233
240
  }
234
241
 
235
- function buildUserPrompt(appMeta, files, omitted, staticRoutes) {
236
- const codeBlob = files.map((f) => `===== FILE: ${f.rel} =====\n${f.content}`).join('\n\n');
242
+ function buildUserPrompt(appMeta, files, omitted, staticRoutes, surface = null) {
243
+ // When a reachability surface is available, tag each file header so the model
244
+ // knows what is the live product vs secondary code, and list the detected
245
+ // link elements on the product surface (seed for LINK actions).
246
+ const reachableRel = new Set();
247
+ if (surface && surface.applied && surface.relOf) {
248
+ for (const abs of surface.reachableClosure) reachableRel.add(surface.relOf(abs));
249
+ for (const abs of surface.entryFiles) reachableRel.add(surface.relOf(abs));
250
+ }
251
+ const tagFor = (rel) => {
252
+ if (!surface || !surface.applied) return '';
253
+ return reachableRel.has(rel) ? ' [PRODUCT SURFACE]' : ' [SECONDARY]';
254
+ };
255
+ const codeBlob = files
256
+ .map((f) => `===== FILE${tagFor(f.rel)}: ${f.rel} =====\n${f.content}`)
257
+ .join('\n\n');
258
+
237
259
  const routeHint = staticRoutes
238
260
  .map((r) => `${r.routePath}${r.isDynamic ? ' (dynamic)' : ''} ← ${r.relFile}`)
239
261
  .join('\n');
@@ -244,10 +266,24 @@ function buildUserPrompt(appMeta, files, omitted, staticRoutes) {
244
266
  ? `Statically-scanned routes (existence is verified — use these hrefs):\n${routeHint}\n`
245
267
  : `No static route list (web app): infer routes/views from the router and navigation calls in the code below.\n`;
246
268
 
269
+ // Detected interactive link elements on the product surface — concrete hrefs
270
+ // the model should turn into LINK actions (verified later against the file).
271
+ let linkBlock = '';
272
+ const links = (surface && surface.productLinks) || [];
273
+ if (links.length) {
274
+ const lines = links
275
+ .slice(0, 60)
276
+ .map((l) => `- [${l.kind}] ${l.target}${l.label ? ` (label: ${l.label})` : ''} ← ${l.file}`)
277
+ .join('\n');
278
+ linkBlock =
279
+ `\nDETECTED LINK ELEMENTS ON THE PRODUCT SURFACE (turn the meaningful ones into "link" actions; navTarget must match the target string exactly; group duplicates):\n${lines}\n`;
280
+ }
281
+
247
282
  return (
248
283
  `App: ${appMeta.name}\nDescription: ${appMeta.description || '(none)'}\n` +
249
284
  `App id / bundle id (if any): ${appMeta.bundleId || '(none)'}\nDependencies: ${(appMeta.dependencies || []).join(', ')}\n\n` +
250
285
  routeBlock +
286
+ linkBlock +
251
287
  omittedNote +
252
288
  `\nManifest schema:\n${manifestSchemaDoc()}\n\n` +
253
289
  `FULL SOURCE CODE (${files.length} files):\n\n${codeBlob}`
@@ -384,11 +420,11 @@ async function analyzeViaProxy({ apiKey, apiUrl, model, system, user }) {
384
420
  */
385
421
  async function analyzeWithAI({
386
422
  appMeta, files, omitted, staticRoutes, model = DEFAULT_MODEL, platform = 'mobile',
387
- useProxy = false, apiKey = '', apiUrl = '',
423
+ useProxy = false, apiKey = '', apiUrl = '', surface = null,
388
424
  }) {
389
425
  const resolved = resolveModel(model);
390
426
  const system = buildSystemPrompt(platform);
391
- const user = buildUserPrompt(appMeta, files, omitted, staticRoutes);
427
+ const user = buildUserPrompt(appMeta, files, omitted, staticRoutes, surface);
392
428
 
393
429
  const t0 = Date.now();
394
430
  let parsed;
package/src/ast.js CHANGED
@@ -106,6 +106,13 @@ function extractFromFile(filePath, appRoot) {
106
106
  // createRouter). Such a file's links act as GLOBAL navigation (available
107
107
  // from any screen), unlike a per-section sidebar.
108
108
  routeHost: false,
109
+ // Interactive LINK elements found in this file: <a href>, <Link href|to>,
110
+ // window.open(...), Linking.openURL(...). Each is {target, kind, label}.
111
+ // kind ∈ internal | external | store | anchor | mailto | tel. These feed the
112
+ // analyzer so web "link/navigation actions" (download, contact, social,
113
+ // jump-to-section, change-language-by-link) are detected with the href as
114
+ // concrete, hallucination-proof evidence — not just state-mutating handlers.
115
+ linkEls: [],
109
116
  // Soft signals that this file sits behind authentication (used ONLY to keep
110
117
  // an orphaned cluster ACTIVE — never to disable). Identifiers/paths that
111
118
  // commonly indicate an auth gate.
@@ -123,6 +130,30 @@ function extractFromFile(filePath, appRoot) {
123
130
  out.navTargets.push(v);
124
131
  }
125
132
 
133
+ // Classify a link/navigation target for the analyzer hint + link actions.
134
+ function classifyLink(value) {
135
+ if (!value || typeof value !== 'string') return null;
136
+ const v = value.trim();
137
+ if (!v || v === '#') return null;
138
+ if (/^mailto:/i.test(v)) return { target: v, kind: 'mailto' };
139
+ if (/^tel:/i.test(v)) return { target: v, kind: 'tel' };
140
+ if (v.startsWith('#')) return { target: v, kind: 'anchor' };
141
+ if (/^https?:\/\//i.test(v) || v.startsWith('//')) {
142
+ const store = /(apps\.apple\.com|itunes\.apple\.com|play\.google\.com|testflight)/i.test(v);
143
+ return { target: v, kind: store ? 'store' : 'external' };
144
+ }
145
+ if (v.startsWith('/')) return { target: v, kind: 'internal' };
146
+ // Template/expression placeholders (e.g. `#${id}`) or unknown — skip as a
147
+ // concrete link (no verifiable literal href).
148
+ return null;
149
+ }
150
+
151
+ function pushLink(value, label) {
152
+ const c = classifyLink(value);
153
+ if (!c) return;
154
+ out.linkEls.push({ target: c.target, kind: c.kind, label: label || null, file: rel });
155
+ }
156
+
126
157
  // GATING constructs only (a redirect/block when unauthenticated) — NOT mere
127
158
  // session reading (useUser/useSession): reading the user does not gate access,
128
159
  // and counting it would wrongly protect public-but-orphaned pages from being
@@ -275,6 +306,12 @@ function extractFromFile(filePath, appRoot) {
275
306
  if (/^Linking\.(openURL|canOpenURL)$/.test(exprText) && node.arguments[0]) {
276
307
  const t = literalText(node.arguments[0]) || node.arguments[0].getText().slice(0, 60);
277
308
  out.linkingCalls.push({ target: t, file: rel });
309
+ pushLink(literalText(node.arguments[0]));
310
+ }
311
+
312
+ // window.open(url, ...) / window.location.assign(url) — web external nav.
313
+ if (/^(window\.open|window\.location\.assign|window\.location\.replace|location\.assign|location\.replace)$/.test(exprText) && node.arguments[0]) {
314
+ pushLink(literalText(node.arguments[0]));
278
315
  }
279
316
 
280
317
  // Zustand: create<T>()(persist((set) => ({...}))) or create((set) => ({...}))
@@ -308,7 +345,11 @@ function extractFromFile(filePath, appRoot) {
308
345
  // Any element carrying an internal route via href/to (next/link <Link>,
309
346
  // <a href>, react-router <Link to>/<Navigate to>/<Redirect to>).
310
347
  const linkTarget = jsxAttr(attrs, ['href', 'to']);
311
- if (linkTarget) pushNavTarget(linkTarget);
348
+ if (linkTarget) {
349
+ pushNavTarget(linkTarget);
350
+ const label = jsxAttr(attrs, ['aria-label', 'title', 'download', 'alt']);
351
+ pushLink(linkTarget, label);
352
+ }
312
353
  // react-router <Route> table → global navigation host.
313
354
  if (tag === 'Route') out.routeHost = true;
314
355
  if (/Button/i.test(tag) || tag === 'TouchableOpacity' || tag === 'Pressable') {
package/src/index.js CHANGED
@@ -96,7 +96,7 @@ const { verifyManifest } = require('./verify');
96
96
  const { wireWebOrb, reportWireResult } = require('./wireWeb');
97
97
  const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
98
98
  const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
99
- const { scoreSurface } = require('./scoreSurface');
99
+ const { scoreSurface, computeSurface } = require('./scoreSurface');
100
100
 
101
101
  function arg(flag, fallback) {
102
102
  const i = process.argv.indexOf(flag);
@@ -275,6 +275,21 @@ async function main() {
275
275
  logDev(`[collect] files=${included.length} (~${estimatedTokens} tokens)` +
276
276
  (omitted.length ? ` omitted=${omitted.length} (over budget)` : ''));
277
277
 
278
+ // ── 2c. Reachability surface (web) — computed BEFORE the AI call so it can
279
+ // GUIDE detection, not just clean up afterwards. It tells the model which
280
+ // files are the live product (reachable from the home) vs secondary code, and
281
+ // hands it the concrete link elements on those pages (download/contact/social/
282
+ // anchors) so landing-page actions stop being invisible. Reused after verify
283
+ // for scoring (no recompute). Best-effort: never blocks analysis.
284
+ let surface = null;
285
+ try {
286
+ surface = computeSurface({ included, analysisRoot, platform });
287
+ logDev(`[surface] pre-analysis applied=${surface.applied} reason=${surface.reason} ` +
288
+ `reachable=${surface.reached.size} links=${surface.productLinks.length}`);
289
+ } catch (e) {
290
+ logDev(`[surface] pre-analysis skipped: ${e.message}`);
291
+ }
292
+
278
293
  let manifest;
279
294
  let provenance;
280
295
  let evidence = {};
@@ -328,7 +343,7 @@ async function main() {
328
343
  'Analyzing your project — may take 1–2 min',
329
344
  () => analyzeWithAI({
330
345
  appMeta, files: included, omitted, staticRoutes: routes, model, platform,
331
- useProxy, apiKey, apiUrl,
346
+ useProxy, apiKey, apiUrl, surface,
332
347
  }),
333
348
  );
334
349
  if (usedModel) model = usedModel;
@@ -390,18 +405,18 @@ async function main() {
390
405
  // Anything uncertain stays ACTIVE (fail towards including). The on/off lives
391
406
  // in the workspace's disabled-intent sets (reused infra); the developer flips
392
407
  // it in the dashboard. Web-first; other platforms are not scored yet.
393
- let surface = { disabledRouteIntents: [], disabledActionIntents: [], stats: { applied: false } };
408
+ let surfaceScore = { disabledRouteIntents: [], disabledActionIntents: [], stats: { applied: false } };
394
409
  try {
395
- surface = scoreSurface({ manifest, evidence, included, analysisRoot, platform });
410
+ surfaceScore = scoreSurface({ manifest, evidence, included, analysisRoot, platform, surface });
396
411
  for (const r of manifest.routes) {
397
- const s = surface.routes && surface.routes[r.intent];
412
+ const s = surfaceScore.routes && surfaceScore.routes[r.intent];
398
413
  if (s) { r.surfaceConfidence = s.confidence; r.surfaceReason = s.reason; }
399
414
  }
400
415
  for (const a of manifest.actions) {
401
- const s = surface.actions && surface.actions[a.intent];
416
+ const s = surfaceScore.actions && surfaceScore.actions[a.intent];
402
417
  if (s) { a.surfaceConfidence = s.confidence; a.surfaceReason = s.reason; }
403
418
  }
404
- logDev(`[surface] ${JSON.stringify(surface.stats)}`);
419
+ logDev(`[surface] ${JSON.stringify(surfaceScore.stats)}`);
405
420
  } catch (e) {
406
421
  // Scoring is best-effort: a failure must never block publishing. Fail OPEN
407
422
  // (everything stays active) — consistent with the "fail towards including".
@@ -437,15 +452,15 @@ async function main() {
437
452
  // the first publish / when the developer has not customized scope (no
438
453
  // clobbering manual choices). Empty when scoring did not apply.
439
454
  scopeDefaults: {
440
- disabledRouteIntents: surface.disabledRouteIntents || [],
441
- disabledActionIntents: surface.disabledActionIntents || [],
455
+ disabledRouteIntents: surfaceScore.disabledRouteIntents || [],
456
+ disabledActionIntents: surfaceScore.disabledActionIntents || [],
442
457
  },
443
458
  };
444
459
 
445
460
  // Informative, non-blocking summary (the dashboard is where scope is managed).
446
- const offR = (surface.disabledRouteIntents || []).length;
447
- const offA = (surface.disabledActionIntents || []).length;
448
- if (surface.stats && surface.stats.applied && (offR || offA)) {
461
+ const offR = (surfaceScore.disabledRouteIntents || []).length;
462
+ const offA = (surfaceScore.disabledActionIntents || []).length;
463
+ if (surfaceScore.stats && surfaceScore.stats.applied && (offR || offA)) {
449
464
  const onR = manifest.routes.length - offR;
450
465
  const onA = manifest.actions.length - offA;
451
466
  logUser(`Main surface (active): ${onR} route(s), ${onA} action(s)`);
@@ -227,61 +227,46 @@ function hasGlobalAuthGate(included, analysisRoot, extracted) {
227
227
  return false;
228
228
  }
229
229
 
230
+ /** Actionable (non-internal-route) link kinds → candidates for LINK actions. */
231
+ const ACTION_LINK_KINDS = new Set(['external', 'store', 'anchor', 'mailto', 'tel']);
232
+
230
233
  /**
231
- * Score every route/action of a manifest by product-surface confidence and
232
- * compute the conservative default-disabled sets.
234
+ * Compute the navigation reachability surface ONCE, from the collected files —
235
+ * independent of any manifest. Used (a) BEFORE the AI call to focus the prompt
236
+ * on the real product surface, and (b) AFTER it by scoreSurface() to score
237
+ * intents. Lifting it earlier is what turns reachability from a post-filter into
238
+ * a signal that GUIDES detection.
233
239
  *
234
- * @param {object} p
235
- * @param {object} p.manifest
236
- * @param {object} p.evidence { routes: {intent:{file}}, actions: {intent:{file}} }
237
- * @param {Array} p.included collected files [{rel, content}]
238
- * @param {string} p.analysisRoot
239
- * @param {string} p.platform
240
240
  * @returns {{
241
- * routes: Object<string,{confidence:string,reason:string}>,
242
- * actions: Object<string,{confidence:string,reason:string}>,
243
- * disabledRouteIntents: string[],
244
- * disabledActionIntents: string[],
245
- * stats: object,
241
+ * applied: boolean, reason: string,
242
+ * extracted: Map, edges: Map, absSet: Set, routeMap: Map,
243
+ * entryFiles: Set<string>, reached: Set<string>, reachableClosure: Set<string>,
244
+ * globalAuthGate: boolean, trustworthy: boolean, resolvableTargets: number,
245
+ * productLinks: Array<{target:string,kind:string,label:?string,file:string}>,
246
+ * relOf: (abs:string)=>string,
246
247
  * }}
247
248
  */
248
- function scoreSurface({ manifest, evidence = {}, included = [], analysisRoot, platform = 'web' }) {
249
- const empty = {
250
- routes: {},
251
- actions: {},
252
- disabledRouteIntents: [],
253
- disabledActionIntents: [],
254
- stats: { applied: false, reason: 'not-web' },
255
- };
256
-
257
- // Web-first. Other platforms: reachability is not computed confidently yet,
258
- // so we return no disables (fail towards including). Honest limitation.
259
- if (platform !== 'web') return empty;
260
- if (!manifest || !Array.isArray(manifest.routes)) return empty;
261
-
249
+ function computeSurface({ included = [], analysisRoot, platform = 'web' }) {
262
250
  const { extracted, edges, absSet } = buildGraphFromFiles(included, analysisRoot);
263
251
  const routeMap = buildWebRouteMap(included, analysisRoot);
252
+ const relOf = (abs) => toPosix(path.relative(analysisRoot, abs));
253
+
254
+ // Web-first. Other platforms: reachability is not computed confidently yet.
255
+ if (platform !== 'web') {
256
+ return {
257
+ applied: false, reason: 'not-web', extracted, edges, absSet, routeMap,
258
+ entryFiles: new Set(), reached: new Set(), reachableClosure: new Set(),
259
+ globalAuthGate: false, trustworthy: false, resolvableTargets: 0,
260
+ productLinks: [], relOf,
261
+ };
262
+ }
264
263
 
265
- const evAbs = (rel) => {
266
- if (!rel) return null;
267
- const abs = path.resolve(analysisRoot, rel);
268
- return absSet.has(abs) ? abs : null;
269
- };
270
- const routeEv = evidence.routes || {};
271
- const actionEv = evidence.actions || {};
272
-
273
- // Global auth gate (root middleware) → keep everything active.
274
264
  const globalAuthGate = hasGlobalAuthGate(included, analysisRoot, extracted);
275
-
276
- // Entry screen file(s). Without one we cannot judge orphans → disable nothing.
277
265
  const entryFiles = findEntryFiles(routeMap, extracted);
278
266
 
279
- // SPA route hosts (react-router/Angular/Vue): their links are GLOBAL nav.
280
267
  const routeHostFiles = [];
281
268
  for (const [abs, ex] of extracted) if (ex.routeHost) routeHostFiles.push(abs);
282
269
 
283
- // Is the navigation graph trustworthy? (Enough resolvable internal links that
284
- // an "orphan" is believable rather than just unparsed dynamic navigation.)
285
270
  const allTargets = new Set();
286
271
  for (const ex of extracted.values()) {
287
272
  for (const t of ex.navTargets || []) {
@@ -290,12 +275,8 @@ function scoreSurface({ manifest, evidence = {}, included = [], analysisRoot, pl
290
275
  }
291
276
  }
292
277
  const trustworthy = allTargets.size >= 2;
278
+ const applied = entryFiles.size > 0 && trustworthy && !globalAuthGate;
293
279
 
294
- const canScore = entryFiles.size > 0 && trustworthy && !globalAuthGate;
295
-
296
- // BFS over screen files from the entry, following nav links found in each
297
- // reached screen's closure AND its layout chain. SPA route-host links are
298
- // seeded as globally available.
299
280
  const reached = new Set(entryFiles);
300
281
  const queue = [...entryFiles];
301
282
  const seedTargets = (files) => {
@@ -307,16 +288,12 @@ function scoreSurface({ manifest, evidence = {}, included = [], analysisRoot, pl
307
288
  }
308
289
  }
309
290
  };
310
- // Global navigation from route hosts (SPA) is reachable from anywhere.
311
291
  seedTargets(routeHostFiles);
312
292
  while (queue.length) {
313
293
  const f = queue.shift();
314
- const shell = [f, ...layoutChain(f, analysisRoot, absSet)];
315
- seedTargets(shell);
294
+ seedTargets([f, ...layoutChain(f, analysisRoot, absSet)]);
316
295
  }
317
296
 
318
- // Closure of everything reachable (for action membership): a non-screen file
319
- // (service/store) is "on the product surface" if a reachable screen imports it.
320
297
  const reachableClosure = new Set();
321
298
  for (const f of reached) {
322
299
  for (const dep of closure(f, edges)) reachableClosure.add(dep);
@@ -325,6 +302,86 @@ function scoreSurface({ manifest, evidence = {}, included = [], analysisRoot, pl
325
302
  }
326
303
  }
327
304
 
305
+ // Link/navigation signals living on the reachable product surface (the files
306
+ // the user actually sees + their shells/closures). Deduped by target. These
307
+ // seed the analyzer so download/contact/social/anchor actions are detected.
308
+ // When reachability is not trustworthy, fall back to ALL files' links (better
309
+ // to surface candidates than to hide them — fail towards including).
310
+ const linkScope = applied ? reachableClosure : new Set(extracted.keys());
311
+ const productLinks = [];
312
+ const seenLink = new Set();
313
+ for (const abs of linkScope) {
314
+ const ex = extracted.get(abs);
315
+ if (!ex || !ex.linkEls) continue;
316
+ for (const l of ex.linkEls) {
317
+ if (!ACTION_LINK_KINDS.has(l.kind)) continue;
318
+ const key = l.kind + '|' + l.target;
319
+ if (seenLink.has(key)) continue;
320
+ seenLink.add(key);
321
+ productLinks.push({ target: l.target, kind: l.kind, label: l.label, file: relOf(abs) });
322
+ }
323
+ }
324
+
325
+ return {
326
+ applied,
327
+ reason: applied
328
+ ? 'scored'
329
+ : globalAuthGate ? 'global-auth-gate'
330
+ : entryFiles.size === 0 ? 'no-entry'
331
+ : !trustworthy ? 'sparse-graph' : 'unknown',
332
+ extracted, edges, absSet, routeMap,
333
+ entryFiles, reached, reachableClosure,
334
+ globalAuthGate, trustworthy, resolvableTargets: allTargets.size,
335
+ productLinks, relOf,
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Score every route/action of a manifest by product-surface confidence and
341
+ * compute the conservative default-disabled sets.
342
+ *
343
+ * @param {object} p
344
+ * @param {object} p.manifest
345
+ * @param {object} p.evidence { routes: {intent:{file}}, actions: {intent:{file}} }
346
+ * @param {Array} p.included collected files [{rel, content}]
347
+ * @param {string} p.analysisRoot
348
+ * @param {string} p.platform
349
+ * @param {object} [p.surface] precomputed computeSurface() result (avoids recompute)
350
+ * @returns {{
351
+ * routes: Object<string,{confidence:string,reason:string}>,
352
+ * actions: Object<string,{confidence:string,reason:string}>,
353
+ * disabledRouteIntents: string[],
354
+ * disabledActionIntents: string[],
355
+ * stats: object,
356
+ * }}
357
+ */
358
+ function scoreSurface({ manifest, evidence = {}, included = [], analysisRoot, platform = 'web', surface = null }) {
359
+ const empty = {
360
+ routes: {},
361
+ actions: {},
362
+ disabledRouteIntents: [],
363
+ disabledActionIntents: [],
364
+ stats: { applied: false, reason: 'not-web' },
365
+ };
366
+
367
+ // Web-first. Other platforms: reachability is not computed confidently yet,
368
+ // so we return no disables (fail towards including). Honest limitation.
369
+ if (platform !== 'web') return empty;
370
+ if (!manifest || !Array.isArray(manifest.routes)) return empty;
371
+
372
+ const surf = surface || computeSurface({ included, analysisRoot, platform });
373
+ const { extracted, edges, absSet, routeMap, entryFiles, reached, reachableClosure, globalAuthGate, trustworthy } = surf;
374
+ const canScore = surf.applied;
375
+ const allTargetsSize = surf.resolvableTargets;
376
+
377
+ const evAbs = (rel) => {
378
+ if (!rel) return null;
379
+ const abs = path.resolve(analysisRoot, rel);
380
+ return absSet.has(abs) ? abs : null;
381
+ };
382
+ const routeEv = evidence.routes || {};
383
+ const actionEv = evidence.actions || {};
384
+
328
385
  const routes = {};
329
386
  const disabledRouteIntents = [];
330
387
  const entryUrls = new Set([...entryFiles].map(() => '/'));
@@ -393,7 +450,7 @@ function scoreSurface({ manifest, evidence = {}, included = [], analysisRoot, pl
393
450
  : 'unknown',
394
451
  entryCount: entryFiles.size,
395
452
  reachableScreens: reached.size,
396
- resolvableTargets: allTargets.size,
453
+ resolvableTargets: allTargetsSize,
397
454
  trustworthy,
398
455
  globalAuthGate,
399
456
  routesDisabled: disabledRouteIntents.length,
@@ -402,4 +459,4 @@ function scoreSurface({ manifest, evidence = {}, included = [], analysisRoot, pl
402
459
  };
403
460
  }
404
461
 
405
- module.exports = { scoreSurface, normUrl, buildWebRouteMap, buildGraphFromFiles };
462
+ module.exports = { scoreSurface, computeSurface, normUrl, buildWebRouteMap, buildGraphFromFiles };
package/src/verify.js CHANGED
@@ -117,6 +117,36 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
117
117
  continue;
118
118
  }
119
119
 
120
+ // LINK/navigation actions: no handler. Verify the navTarget appears VERBATIM
121
+ // in the evidence file (the real href/URL/anchor) — concrete, hallucination-
122
+ // proof evidence. No wiring is generated for these.
123
+ if (action.kind === 'link') {
124
+ const target = String(action.navTarget || ev.href || '').trim();
125
+ if (!target) {
126
+ provenance.actions.push({
127
+ intent: action.intent, included: false,
128
+ reason: `link action '${action.intent}' has no navTarget — DROPPED`,
129
+ });
130
+ continue;
131
+ }
132
+ if (!content.includes(target)) {
133
+ provenance.actions.push({
134
+ intent: action.intent, included: false,
135
+ reason: `link action navTarget '${target}' not found verbatim in '${evFile}' — DROPPED`,
136
+ });
137
+ continue;
138
+ }
139
+ // Normalize: link actions carry no handler and never require wiring.
140
+ action.handler = '';
141
+ actions.push(action);
142
+ provenance.actions.push({
143
+ intent: action.intent, included: true,
144
+ verification: `link target '${target}' confirmed in ${evFile}`,
145
+ evidence: ev,
146
+ });
147
+ continue;
148
+ }
149
+
120
150
  // The handler must be a real identifier in the evidence file. Also accept
121
151
  // any identifier from the symbol (covers "store.addItem"-style handlers).
122
152
  const candidates = [action.handler, ...identifiersIn(ev.symbol)].filter(Boolean);
@@ -612,6 +612,9 @@ function prepareHandlerPlan({ appRoot, manifest, evidence, wiring = {}, fyodosDi
612
612
 
613
613
  for (const action of actions) {
614
614
  const intent = action.intent;
615
+ // Link/navigation actions have no handler and need NO wiring — the client
616
+ // performs the navigation in navTarget. Skip them entirely here.
617
+ if (action.kind === 'link') continue;
615
618
  const handler = action.handler || intent;
616
619
  const ev = evActions[intent] || {};
617
620
  const evFile = normRel(ev.file);
@@ -1373,6 +1376,9 @@ async function runAutoCorrectionLoop(ctx) {
1373
1376
 
1374
1377
  for (const action of manifest.actions || []) {
1375
1378
  const intent = action.intent;
1379
+ // Link/navigation actions need no wiring (no handler); never run the wiring
1380
+ // verify/auto-correct loop for them.
1381
+ if (action.kind === 'link') continue;
1376
1382
  let attempt = 0;
1377
1383
  let lastError = '';
1378
1384
  let resolved = null;