@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 +2 -2
- package/src/aiAnalyze.js +44 -8
- package/src/ast.js +42 -1
- package/src/index.js +27 -12
- package/src/scoreSurface.js +110 -53
- package/src/verify.js +30 -0
- package/src/wireHandlers.js +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
'
|
|
103
|
+
'Web actions come in TWO kinds, BOTH first-class — detect 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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
408
|
+
let surfaceScore = { disabledRouteIntents: [], disabledActionIntents: [], stats: { applied: false } };
|
|
394
409
|
try {
|
|
395
|
-
|
|
410
|
+
surfaceScore = scoreSurface({ manifest, evidence, included, analysisRoot, platform, surface });
|
|
396
411
|
for (const r of manifest.routes) {
|
|
397
|
-
const s =
|
|
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 =
|
|
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(
|
|
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:
|
|
441
|
-
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 = (
|
|
447
|
-
const offA = (
|
|
448
|
-
if (
|
|
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)`);
|
package/src/scoreSurface.js
CHANGED
|
@@ -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
|
-
*
|
|
232
|
-
*
|
|
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
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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);
|
package/src/wireHandlers.js
CHANGED
|
@@ -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;
|