@fiodos/cli 0.1.7 → 0.1.10
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 +3 -2
- package/src/ast.js +96 -2
- package/src/graph.js +1 -1
- package/src/index.js +145 -54
- package/src/scoreSurface.js +405 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@fiodos/core": "^0.1.0",
|
|
23
23
|
"esbuild": "^0.28.1",
|
|
24
|
-
"jsdom": "^24.0.0"
|
|
24
|
+
"jsdom": "^24.0.0",
|
|
25
|
+
"typescript": "^5.6.0"
|
|
25
26
|
},
|
|
26
27
|
"license": "SEE LICENSE IN LICENSE",
|
|
27
28
|
"publishConfig": {
|
package/src/ast.js
CHANGED
|
@@ -20,8 +20,30 @@
|
|
|
20
20
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const path = require('path');
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
|
|
24
|
+
// Load the TypeScript compiler (real parser, real AST). `typescript` is a
|
|
25
|
+
// declared dependency of this package, so when the CLI is installed from npm
|
|
26
|
+
// (or run via `npx`) it resolves from the CLI's own node_modules. The monorepo
|
|
27
|
+
// path is only a dev-time fallback for the hoisted workspace install.
|
|
28
|
+
const ts = loadTypeScript();
|
|
29
|
+
|
|
30
|
+
function loadTypeScript() {
|
|
31
|
+
try {
|
|
32
|
+
// Installed/npx case: resolves from @fiodos/cli's node_modules.
|
|
33
|
+
return require('typescript');
|
|
34
|
+
} catch {
|
|
35
|
+
// Monorepo dev fallback: TypeScript is hoisted to the repo root.
|
|
36
|
+
try {
|
|
37
|
+
return require(path.join(__dirname, '../../../node_modules/typescript'));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'Fiodos CLI could not load its TypeScript dependency. This usually means ' +
|
|
41
|
+
'a corrupted install — try `npm cache clean --force` and re-run, or reinstall ' +
|
|
42
|
+
`@fiodos/cli. Original error: ${e.message}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
25
47
|
|
|
26
48
|
function parseFile(filePath) {
|
|
27
49
|
const text = fs.readFileSync(filePath, 'utf8');
|
|
@@ -73,8 +95,42 @@ function extractFromFile(filePath, appRoot) {
|
|
|
73
95
|
imports: [],
|
|
74
96
|
linkingCalls: [],
|
|
75
97
|
defaultExportName: null,
|
|
98
|
+
// Internal navigation destinations referenced in this file (route strings):
|
|
99
|
+
// <Link href|to>, <a href>, router.push/replace, navigate(), redirect().
|
|
100
|
+
// Used by the surface-scoring layer to build the navigation reachability
|
|
101
|
+
// graph (which screens are reachable from the deployed entry). External
|
|
102
|
+
// links (http, mailto, tel, #anchors) are excluded.
|
|
103
|
+
navTargets: [],
|
|
104
|
+
// True when this file DEFINES the app's route table (react-router <Route>,
|
|
105
|
+
// createBrowserRouter, Angular RouterModule.forRoot/provideRouter, Vue
|
|
106
|
+
// createRouter). Such a file's links act as GLOBAL navigation (available
|
|
107
|
+
// from any screen), unlike a per-section sidebar.
|
|
108
|
+
routeHost: false,
|
|
109
|
+
// Soft signals that this file sits behind authentication (used ONLY to keep
|
|
110
|
+
// an orphaned cluster ACTIVE — never to disable). Identifiers/paths that
|
|
111
|
+
// commonly indicate an auth gate.
|
|
112
|
+
authSignals: [],
|
|
76
113
|
};
|
|
77
114
|
|
|
115
|
+
// Collect an internal route string if it looks like an in-app path.
|
|
116
|
+
function pushNavTarget(value) {
|
|
117
|
+
if (!value || typeof value !== 'string') return;
|
|
118
|
+
const v = value.trim();
|
|
119
|
+
if (!v) return;
|
|
120
|
+
// Only same-app paths. Drop external/protocol/anchor/empty targets.
|
|
121
|
+
if (!v.startsWith('/')) return;
|
|
122
|
+
if (v.startsWith('//')) return; // protocol-relative external
|
|
123
|
+
out.navTargets.push(v);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// GATING constructs only (a redirect/block when unauthenticated) — NOT mere
|
|
127
|
+
// session reading (useUser/useSession): reading the user does not gate access,
|
|
128
|
+
// and counting it would wrongly protect public-but-orphaned pages from being
|
|
129
|
+
// disabled. Used solely to KEEP an orphaned cluster active (SaaS guard).
|
|
130
|
+
const AUTH_HINT_RE =
|
|
131
|
+
/(requireAuth|withAuth|ProtectedRoute|RequireAuth|AuthGuard|authGuard|ensureAuth|canActivate|redirectToLogin|RedirectToSignIn|SignedOut)/;
|
|
132
|
+
const AUTH_PATH_RE = /\/(login|signin|sign-in|sign_in)\b/i;
|
|
133
|
+
|
|
78
134
|
function jsxAttr(attrs, names) {
|
|
79
135
|
for (const attr of attrs.properties) {
|
|
80
136
|
if (ts.isJsxAttribute(attr) && names.includes(attr.name.getText())) {
|
|
@@ -118,6 +174,10 @@ function extractFromFile(filePath, appRoot) {
|
|
|
118
174
|
}
|
|
119
175
|
}
|
|
120
176
|
out.imports.push({ from: node.moduleSpecifier.text, names });
|
|
177
|
+
for (const n of names) {
|
|
178
|
+
if (AUTH_HINT_RE.test(n)) out.authSignals.push(n);
|
|
179
|
+
}
|
|
180
|
+
if (AUTH_PATH_RE.test(node.moduleSpecifier.text)) out.authSignals.push(node.moduleSpecifier.text);
|
|
121
181
|
}
|
|
122
182
|
|
|
123
183
|
// default export name (screen component)
|
|
@@ -157,6 +217,34 @@ function extractFromFile(filePath, appRoot) {
|
|
|
157
217
|
}
|
|
158
218
|
}
|
|
159
219
|
out.navCalls.push({ verb, target, file: rel });
|
|
220
|
+
if (verb !== 'back' && verb !== 'goBack') pushNavTarget(target);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Next.js / framework redirects: redirect('/x'), permanentRedirect('/x').
|
|
224
|
+
if (/^(redirect|permanentRedirect)$/.test(exprText) && node.arguments[0]) {
|
|
225
|
+
pushNavTarget(literalText(node.arguments[0]));
|
|
226
|
+
}
|
|
227
|
+
// react-router imperative: navigate('/x') / history.push('/x').
|
|
228
|
+
if (/(^|\.)navigate$/.test(exprText) && node.arguments[0]) {
|
|
229
|
+
pushNavTarget(literalText(node.arguments[0]));
|
|
230
|
+
}
|
|
231
|
+
// Bare push('/x') from a destructured useRouter()/useNavigate() hook.
|
|
232
|
+
if (exprText === 'push' && node.arguments[0]) {
|
|
233
|
+
pushNavTarget(literalText(node.arguments[0]));
|
|
234
|
+
}
|
|
235
|
+
// Route-table definitions → this file's links are GLOBAL navigation.
|
|
236
|
+
if (/^(createBrowserRouter|createHashRouter|createMemoryRouter|createRouter)$/.test(exprText)) {
|
|
237
|
+
out.routeHost = true;
|
|
238
|
+
}
|
|
239
|
+
if (/\.(forRoot|forChild)$/.test(exprText) || /^provideRouter$/.test(exprText)) {
|
|
240
|
+
out.routeHost = true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Auth gate by call: redirect('/login') etc. is captured above as a nav
|
|
244
|
+
// target; also flag it as an auth signal when the path looks like a gate.
|
|
245
|
+
if (/^(redirect|replace|push)$/.test(exprText.split('.').pop() || '') && node.arguments[0]) {
|
|
246
|
+
const t = literalText(node.arguments[0]);
|
|
247
|
+
if (t && AUTH_PATH_RE.test(t)) out.authSignals.push(t);
|
|
160
248
|
}
|
|
161
249
|
|
|
162
250
|
// Alert.alert(title, message?, buttons?)
|
|
@@ -217,6 +305,12 @@ function extractFromFile(filePath, appRoot) {
|
|
|
217
305
|
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
|
|
218
306
|
const tag = node.tagName.getText();
|
|
219
307
|
const attrs = node.attributes;
|
|
308
|
+
// Any element carrying an internal route via href/to (next/link <Link>,
|
|
309
|
+
// <a href>, react-router <Link to>/<Navigate to>/<Redirect to>).
|
|
310
|
+
const linkTarget = jsxAttr(attrs, ['href', 'to']);
|
|
311
|
+
if (linkTarget) pushNavTarget(linkTarget);
|
|
312
|
+
// react-router <Route> table → global navigation host.
|
|
313
|
+
if (tag === 'Route') out.routeHost = true;
|
|
220
314
|
if (/Button/i.test(tag) || tag === 'TouchableOpacity' || tag === 'Pressable') {
|
|
221
315
|
const title = jsxAttr(attrs, ['title', 'label', 'accessibilityLabel']);
|
|
222
316
|
const handler = jsxAttrHandlerName(attrs, ['onPress']);
|
package/src/graph.js
CHANGED
package/src/index.js
CHANGED
|
@@ -96,6 +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
100
|
|
|
100
101
|
function arg(flag, fallback) {
|
|
101
102
|
const i = process.argv.indexOf(flag);
|
|
@@ -161,24 +162,49 @@ async function withSpinner(label, work) {
|
|
|
161
162
|
const orbFrames = ['◴', '◷', '◶', '◵'];
|
|
162
163
|
const brailleFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
163
164
|
const { blue, cyan, dim, reset } = fyodosTermColors();
|
|
165
|
+
let currentLabel = label;
|
|
164
166
|
let frame = 0;
|
|
167
|
+
let id = null;
|
|
165
168
|
const start = Date.now();
|
|
166
|
-
|
|
169
|
+
|
|
170
|
+
const render = () => {
|
|
167
171
|
const sec = Math.floor((Date.now() - start) / 1000);
|
|
168
172
|
const orb = orbFrames[frame % orbFrames.length];
|
|
169
173
|
const tail = brailleFrames[frame % brailleFrames.length];
|
|
170
174
|
const line =
|
|
171
|
-
`${cyan}${orb}${reset} ${blue}Fiodos${reset} ${dim}${tail}${reset} ${
|
|
175
|
+
`${cyan}${orb}${reset} ${blue}Fiodos${reset} ${dim}${tail}${reset} ${currentLabel}… ${dim}${sec}s${reset}`;
|
|
172
176
|
process.stderr.write(`\r\x1b[K${line}`);
|
|
173
177
|
frame += 1;
|
|
174
178
|
};
|
|
175
|
-
|
|
176
|
-
const
|
|
179
|
+
|
|
180
|
+
const clearLine = () => process.stderr.write('\r\x1b[K');
|
|
181
|
+
|
|
182
|
+
const startSpinner = () => {
|
|
183
|
+
render();
|
|
184
|
+
id = setInterval(render, 140);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const pause = () => {
|
|
188
|
+
if (id) {
|
|
189
|
+
clearInterval(id);
|
|
190
|
+
id = null;
|
|
191
|
+
clearLine();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const resume = () => {
|
|
196
|
+
if (!id) startSpinner();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const setLabel = (next) => {
|
|
200
|
+
currentLabel = next;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
startSpinner();
|
|
177
204
|
try {
|
|
178
|
-
return await work();
|
|
205
|
+
return await work({ setLabel, pause, resume });
|
|
179
206
|
} finally {
|
|
180
|
-
|
|
181
|
-
process.stderr.write('\r\x1b[K');
|
|
207
|
+
pause();
|
|
182
208
|
}
|
|
183
209
|
}
|
|
184
210
|
|
|
@@ -297,10 +323,9 @@ async function main() {
|
|
|
297
323
|
}
|
|
298
324
|
|
|
299
325
|
// ── 3. AI reads the code ────────────────────────────────────────────────
|
|
300
|
-
logUser('Analyzing your project — may take 1–2 min');
|
|
301
326
|
const { parsed, usage, costUSD, elapsedMs, promptChars, model: usedModel, analysisToken: token } =
|
|
302
327
|
await withSpinner(
|
|
303
|
-
'Analyzing your project
|
|
328
|
+
'Analyzing your project — may take 1–2 min',
|
|
304
329
|
() => analyzeWithAI({
|
|
305
330
|
appMeta, files: included, omitted, staticRoutes: routes, model, platform,
|
|
306
331
|
useProxy, apiKey, apiUrl,
|
|
@@ -355,6 +380,34 @@ async function main() {
|
|
|
355
380
|
// the rest 'allow'. Closed-by-default still applies regardless of this hint.
|
|
356
381
|
annotateExternalAgentRecommendations(manifest);
|
|
357
382
|
|
|
383
|
+
// ── 4c. Product-surface scoring (conservative, default-OFF for clear noise).
|
|
384
|
+
// Existence is already proven; this asks the SEPARATE question "is this part
|
|
385
|
+
// of the live product surface the orb should expose by default?". Routes/
|
|
386
|
+
// actions whose screens are UNREACHABLE by navigation from the deployed entry
|
|
387
|
+
// (orphans / dead code / a secondary app mirrored in the repo) AND not behind
|
|
388
|
+
// auth are annotated low-confidence and published DISABLED-by-default — never
|
|
389
|
+
// dropped (no new hallucination risk) and never empties an auth-gated app.
|
|
390
|
+
// Anything uncertain stays ACTIVE (fail towards including). The on/off lives
|
|
391
|
+
// in the workspace's disabled-intent sets (reused infra); the developer flips
|
|
392
|
+
// it in the dashboard. Web-first; other platforms are not scored yet.
|
|
393
|
+
let surface = { disabledRouteIntents: [], disabledActionIntents: [], stats: { applied: false } };
|
|
394
|
+
try {
|
|
395
|
+
surface = scoreSurface({ manifest, evidence, included, analysisRoot, platform });
|
|
396
|
+
for (const r of manifest.routes) {
|
|
397
|
+
const s = surface.routes && surface.routes[r.intent];
|
|
398
|
+
if (s) { r.surfaceConfidence = s.confidence; r.surfaceReason = s.reason; }
|
|
399
|
+
}
|
|
400
|
+
for (const a of manifest.actions) {
|
|
401
|
+
const s = surface.actions && surface.actions[a.intent];
|
|
402
|
+
if (s) { a.surfaceConfidence = s.confidence; a.surfaceReason = s.reason; }
|
|
403
|
+
}
|
|
404
|
+
logDev(`[surface] ${JSON.stringify(surface.stats)}`);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
// Scoring is best-effort: a failure must never block publishing. Fail OPEN
|
|
407
|
+
// (everything stays active) — consistent with the "fail towards including".
|
|
408
|
+
logDev(`[surface] skipped: ${e.message}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
358
411
|
// ── 5. Final gate: the real @fiodos/core validator ─────────────────────────
|
|
359
412
|
// Loaded as a package dependency (not a monorepo path) so it resolves when
|
|
360
413
|
// the CLI is installed from npm.
|
|
@@ -373,14 +426,34 @@ async function main() {
|
|
|
373
426
|
logDev(`[done] manifest: ${manifest.routes.length} routes, ${manifest.actions.length} actions`);
|
|
374
427
|
logDev(`[done] output → ${outDir}`);
|
|
375
428
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
429
|
+
const publishing = process.argv.includes('--publish');
|
|
430
|
+
const publishMeta = {
|
|
431
|
+
...analysisMeta,
|
|
432
|
+
generatedAt: new Date().toISOString(),
|
|
433
|
+
routesIncluded: manifest.routes.length,
|
|
434
|
+
actionsIncluded: manifest.actions.length,
|
|
435
|
+
// Conservative default-OFF set for low-confidence (orphan) routes/actions.
|
|
436
|
+
// The backend applies these to the workspace's disabled-intent sets ONLY on
|
|
437
|
+
// the first publish / when the developer has not customized scope (no
|
|
438
|
+
// clobbering manual choices). Empty when scoring did not apply.
|
|
439
|
+
scopeDefaults: {
|
|
440
|
+
disabledRouteIntents: surface.disabledRouteIntents || [],
|
|
441
|
+
disabledActionIntents: surface.disabledActionIntents || [],
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// 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)) {
|
|
449
|
+
const onR = manifest.routes.length - offR;
|
|
450
|
+
const onA = manifest.actions.length - offA;
|
|
451
|
+
logUser(`Main surface (active): ${onR} route(s), ${onA} action(s)`);
|
|
452
|
+
logUser(
|
|
453
|
+
`Detected but off by default: ${offR} route(s), ${offA} action(s) — ` +
|
|
454
|
+
`not reachable from your app's home (likely internal/secondary screens)`,
|
|
455
|
+
);
|
|
456
|
+
logUser('Review or re-enable any of them in your dashboard → Agent configuration');
|
|
384
457
|
}
|
|
385
458
|
|
|
386
459
|
// ── 6. Web: offer to wire the orb into the app (consent-based) ─────────────
|
|
@@ -392,38 +465,7 @@ async function main() {
|
|
|
392
465
|
const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
|
|
393
466
|
const noWire = process.argv.includes('--no-wire');
|
|
394
467
|
let envWriteResult = null;
|
|
395
|
-
|
|
396
|
-
// --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
|
|
397
|
-
// isolation without modifying the app's entrypoint / package.json).
|
|
398
|
-
if (!process.argv.includes('--no-orb-wire')) {
|
|
399
|
-
const result = await wireWebOrb(analysisRoot, {
|
|
400
|
-
apiUrl,
|
|
401
|
-
colors: fyodosTermColors(),
|
|
402
|
-
assumeYes,
|
|
403
|
-
noWire,
|
|
404
|
-
runTest: !process.argv.includes('--no-wire-test'),
|
|
405
|
-
});
|
|
406
|
-
reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() });
|
|
407
|
-
// Tell the dashboard the orb is wired — no need to boot localhost for the
|
|
408
|
-
// onboarding checker to complete.
|
|
409
|
-
if (
|
|
410
|
-
process.argv.includes('--publish') &&
|
|
411
|
-
apiKey &&
|
|
412
|
-
(result.status === 'added' || result.status === 'already')
|
|
413
|
-
) {
|
|
414
|
-
await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// ── 7. Web: SECOND consent — connect detected actions to real app functions.
|
|
419
|
-
// The orb is mounted but the agent cannot DO anything until each manifest
|
|
420
|
-
// action is bridged to the function that performs it. We prepare that wiring,
|
|
421
|
-
// write a readable document into the project's Fiodos folder, and ask a
|
|
422
|
-
// separate, informed [yes/no] before touching anything. Security is enforced
|
|
423
|
-
// upstream by the engine, so generated handlers can never skip a confirmation.
|
|
424
468
|
const { runPostWireTest } = require('./postWireTest');
|
|
425
|
-
// Corrector: when a wired action fails install-time verification, re-prompt the
|
|
426
|
-
// AI with the concrete error to fix THAT action. Disable with --no-self-correct.
|
|
427
469
|
const selfCorrect = !process.argv.includes('--no-self-correct');
|
|
428
470
|
const corrector = selfCorrect
|
|
429
471
|
? async (args) => {
|
|
@@ -431,7 +473,7 @@ async function main() {
|
|
|
431
473
|
return fixed;
|
|
432
474
|
}
|
|
433
475
|
: null;
|
|
434
|
-
const
|
|
476
|
+
const handlerOpts = {
|
|
435
477
|
manifest,
|
|
436
478
|
evidence,
|
|
437
479
|
wiring,
|
|
@@ -446,13 +488,54 @@ async function main() {
|
|
|
446
488
|
corrector,
|
|
447
489
|
files: included,
|
|
448
490
|
maxAttempts: Number(process.env.FYODOS_WIRE_MAX_ATTEMPTS || 3),
|
|
449
|
-
}
|
|
450
|
-
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const runWebWire = async ({ setLabel, pause, resume }) => {
|
|
494
|
+
// --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
|
|
495
|
+
// isolation without modifying the app's entrypoint / package.json).
|
|
496
|
+
if (!process.argv.includes('--no-orb-wire')) {
|
|
497
|
+
setLabel('Mounting the orb in your app');
|
|
498
|
+
if (!assumeYes) pause();
|
|
499
|
+
const result = await wireWebOrb(analysisRoot, {
|
|
500
|
+
apiUrl,
|
|
501
|
+
colors: fyodosTermColors(),
|
|
502
|
+
assumeYes,
|
|
503
|
+
noWire,
|
|
504
|
+
runTest: !process.argv.includes('--no-wire-test'),
|
|
505
|
+
});
|
|
506
|
+
if (!assumeYes) resume();
|
|
507
|
+
reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() });
|
|
508
|
+
if (
|
|
509
|
+
publishing &&
|
|
510
|
+
apiKey &&
|
|
511
|
+
(result.status === 'added' || result.status === 'already')
|
|
512
|
+
) {
|
|
513
|
+
await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
setLabel('Wiring actions to your app');
|
|
518
|
+
if (!assumeYes) pause();
|
|
519
|
+
const handlerResult = await wireHandlers(analysisRoot, handlerOpts);
|
|
520
|
+
if (!assumeYes) resume();
|
|
521
|
+
reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
if (publishing) {
|
|
525
|
+
loadAppEnv(analysisRoot);
|
|
526
|
+
await withSpinner('Publishing to your project', async (spinner) => {
|
|
527
|
+
await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
|
|
528
|
+
await runWebWire(spinner);
|
|
529
|
+
});
|
|
530
|
+
logUser('Published to your project');
|
|
531
|
+
} else {
|
|
532
|
+
await withSpinner('Mounting the orb in your app', runWebWire);
|
|
533
|
+
}
|
|
451
534
|
|
|
452
535
|
// ── 8. API key in environment (LAST step — always asks; independent of --yes)
|
|
453
536
|
// --yes only skips orb/handler prompts. --write-env-yes skips this one (CI).
|
|
454
537
|
// If the developer declines, we show a copy-paste reminder here at the end.
|
|
455
|
-
if (
|
|
538
|
+
if (publishing && !process.argv.includes('--no-write-env')) {
|
|
456
539
|
try {
|
|
457
540
|
envWriteResult = await offerWriteEnv({
|
|
458
541
|
appRoot: analysisRoot,
|
|
@@ -473,6 +556,12 @@ async function main() {
|
|
|
473
556
|
logDev(`[write-env] skipped: ${e.message}`);
|
|
474
557
|
}
|
|
475
558
|
}
|
|
559
|
+
} else if (publishing) {
|
|
560
|
+
loadAppEnv(analysisRoot);
|
|
561
|
+
await withSpinner('Publishing to your project', () =>
|
|
562
|
+
publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true }),
|
|
563
|
+
);
|
|
564
|
+
logUser('Published to your project');
|
|
476
565
|
}
|
|
477
566
|
}
|
|
478
567
|
|
|
@@ -693,7 +782,7 @@ function printQuotaBlocked(quota) {
|
|
|
693
782
|
);
|
|
694
783
|
}
|
|
695
784
|
|
|
696
|
-
async function publishManifest(manifest, meta, analysisToken = null) {
|
|
785
|
+
async function publishManifest(manifest, meta, analysisToken = null, opts = {}) {
|
|
697
786
|
const { apiKey, apiUrl } = resolveApiTarget();
|
|
698
787
|
if (!apiKey) {
|
|
699
788
|
console.error(
|
|
@@ -726,7 +815,9 @@ async function publishManifest(manifest, meta, analysisToken = null) {
|
|
|
726
815
|
process.exit(1);
|
|
727
816
|
}
|
|
728
817
|
const body = await res.json();
|
|
729
|
-
|
|
818
|
+
if (!opts.skipSuccessLog) {
|
|
819
|
+
logUser('Published to your project');
|
|
820
|
+
}
|
|
730
821
|
logDev(`[publish] OK — ${body.routes} routes, ${body.actions} actions received at ${body.receivedAt}`);
|
|
731
822
|
|
|
732
823
|
// Self-check: confirm the orb will find this manifest (silent unless --verbose).
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface scoring — "does this route/action belong to the product surface the
|
|
3
|
+
* developer wants the orb to expose?", separate from "does it exist in code?"
|
|
4
|
+
* (that is the verifier's job, untouched here).
|
|
5
|
+
*
|
|
6
|
+
* The existence verifier guarantees every route/action is backed by real code.
|
|
7
|
+
* But disorganized projects ship code that is NOT the live product surface:
|
|
8
|
+
* dead screens, work-in-progress, or a whole secondary app mirrored inside the
|
|
9
|
+
* web frontend (e.g. a marketing landing that also contains the full app under
|
|
10
|
+
* route groups). The orb should not offer those by default.
|
|
11
|
+
*
|
|
12
|
+
* PRINCIPLE (decided product policy): we cannot read the developer's INTENT
|
|
13
|
+
* from code alone (dead code and a brand-new feature look identical). So we
|
|
14
|
+
* make a CONSERVATIVE automatic guess and pair it with cheap confirmation:
|
|
15
|
+
* - high confidence → published ACTIVE (default-on).
|
|
16
|
+
* - low confidence → published DISABLED-by-default (still present, the dev
|
|
17
|
+
* re-enables it in the dashboard with one click).
|
|
18
|
+
* We never DROP anything (no new hallucination risk) and we FAIL TOWARDS
|
|
19
|
+
* INCLUDING: anything uncertain stays ACTIVE. The only thing we turn off by
|
|
20
|
+
* default is what is CLEARLY not the product:
|
|
21
|
+
* - a route whose screen is UNREACHABLE by navigation from the deployed
|
|
22
|
+
* public entry (orphan), AND
|
|
23
|
+
* - that shows NO sign of sitting behind authentication (so we never empty a
|
|
24
|
+
* login-gated SaaS — the auth guard), AND
|
|
25
|
+
* - only when the navigation graph is trustworthy enough to believe an orphan
|
|
26
|
+
* really is an orphan (else we disable nothing).
|
|
27
|
+
*
|
|
28
|
+
* SIGNAL: navigation reachability from the deployed entry, computed over the
|
|
29
|
+
* project's import/navigation graph. Web-first (Next app/pages router, plus a
|
|
30
|
+
* generic SPA route-host fallback). For platforms where reachability cannot be
|
|
31
|
+
* computed confidently we return NO disables (fail towards including).
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const { extractFromFile } = require('./ast');
|
|
38
|
+
const { closure } = require('./graph');
|
|
39
|
+
|
|
40
|
+
const EXTS = ['.tsx', '.ts', '.jsx', '.js'];
|
|
41
|
+
|
|
42
|
+
/** Normalize an internal route string for matching (drop query/hash/trailing). */
|
|
43
|
+
function normUrl(p) {
|
|
44
|
+
if (!p) return null;
|
|
45
|
+
let s = String(p).split('?')[0].split('#')[0].trim();
|
|
46
|
+
if (!s.startsWith('/')) return null;
|
|
47
|
+
if (s.length > 1) s = s.replace(/\/+$/, '');
|
|
48
|
+
// Collapse Next dynamic [param] / :param into a wildcard token for matching.
|
|
49
|
+
s = s.replace(/\[[^/\]]+\]/g, ':p').replace(/:[^/]+/g, ':p');
|
|
50
|
+
return s || '/';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toPosix(p) {
|
|
54
|
+
return p.replace(/\\/g, '/');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build an import graph over the ALREADY-COLLECTED files (not a fresh FS walk),
|
|
59
|
+
* so it sees the exact code the analysis saw and the correct app root. Reuses
|
|
60
|
+
* the extended AST extractor (navTargets / authSignals / routeHost).
|
|
61
|
+
*
|
|
62
|
+
* @returns {{ extracted: Map<string,object>, edges: Map<string,Set<string>>, absOf: Map<string,string> }}
|
|
63
|
+
* extracted/edges are keyed by ABSOLUTE path; absOf maps rel → abs.
|
|
64
|
+
*/
|
|
65
|
+
function buildGraphFromFiles(included, analysisRoot) {
|
|
66
|
+
const absOf = new Map();
|
|
67
|
+
const absSet = new Set();
|
|
68
|
+
for (const f of included) {
|
|
69
|
+
const abs = path.resolve(analysisRoot, f.rel);
|
|
70
|
+
absOf.set(toPosix(f.rel), abs);
|
|
71
|
+
absSet.add(abs);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const extracted = new Map();
|
|
75
|
+
for (const f of included) {
|
|
76
|
+
const abs = path.resolve(analysisRoot, f.rel);
|
|
77
|
+
if (!EXTS.includes(path.extname(abs))) continue;
|
|
78
|
+
let ex;
|
|
79
|
+
try {
|
|
80
|
+
ex = extractFromFile(abs, analysisRoot);
|
|
81
|
+
} catch {
|
|
82
|
+
ex = { file: f.rel, imports: [], navTargets: [], authSignals: [], routeHost: false };
|
|
83
|
+
}
|
|
84
|
+
extracted.set(abs, ex);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const resolveImport = (fromAbs, spec) => {
|
|
88
|
+
let base = null;
|
|
89
|
+
if (spec.startsWith('.')) base = path.resolve(path.dirname(fromAbs), spec);
|
|
90
|
+
else if (spec.startsWith('@/')) base = path.join(analysisRoot, spec.slice(2));
|
|
91
|
+
else if (spec.startsWith('~/')) base = path.join(analysisRoot, spec.slice(2));
|
|
92
|
+
else return null; // bare package — out of scope
|
|
93
|
+
for (const ext of ['', ...EXTS]) {
|
|
94
|
+
if (absSet.has(base + ext)) return base + ext;
|
|
95
|
+
}
|
|
96
|
+
for (const ext of EXTS) {
|
|
97
|
+
const idx = path.join(base, 'index' + ext);
|
|
98
|
+
if (absSet.has(idx)) return idx;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const edges = new Map();
|
|
104
|
+
for (const [abs, ex] of extracted) {
|
|
105
|
+
const deps = new Set();
|
|
106
|
+
for (const imp of ex.imports || []) {
|
|
107
|
+
const r = resolveImport(abs, imp.from);
|
|
108
|
+
if (r) deps.add(r);
|
|
109
|
+
}
|
|
110
|
+
edges.set(abs, deps);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { extracted, edges, absOf, absSet };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Map a deployed URL path → screen file (absolute), from the collected files.
|
|
118
|
+
* Handles Next App Router (app/.../page.*) and Pages Router (pages/...). Other
|
|
119
|
+
* frameworks rely on manifest evidence + the SPA route-host fallback instead.
|
|
120
|
+
*/
|
|
121
|
+
function buildWebRouteMap(included, analysisRoot) {
|
|
122
|
+
const map = new Map(); // normUrl -> absFile
|
|
123
|
+
const add = (url, abs) => {
|
|
124
|
+
const u = normUrl(url);
|
|
125
|
+
if (u && !map.has(u)) map.set(u, abs);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
for (const f of included) {
|
|
129
|
+
const rel = toPosix(f.rel);
|
|
130
|
+
const abs = path.resolve(analysisRoot, f.rel);
|
|
131
|
+
|
|
132
|
+
// App Router: (src/)app/<segments>/page.ext (also route.ts is NOT a screen)
|
|
133
|
+
let m = rel.match(/^(?:src\/)?app\/(.*\/)?page\.(?:tsx|ts|jsx|js)$/);
|
|
134
|
+
if (m) {
|
|
135
|
+
const segs = (m[1] || '')
|
|
136
|
+
.split('/')
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.filter((s) => !(s.startsWith('(') && s.endsWith(')'))) // route groups
|
|
139
|
+
.filter((s) => !s.startsWith('@')); // parallel-route slots
|
|
140
|
+
add('/' + segs.join('/'), abs);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Pages Router: (src/)pages/<path>.ext (skip _app/_document/api)
|
|
145
|
+
m = rel.match(/^(?:src\/)?pages\/(.*)\.(?:tsx|ts|jsx|js)$/);
|
|
146
|
+
if (m) {
|
|
147
|
+
const p = m[1];
|
|
148
|
+
if (/^_(app|document|error)$/.test(p) || p.startsWith('api/')) continue;
|
|
149
|
+
const segs = p
|
|
150
|
+
.split('/')
|
|
151
|
+
.filter((s) => !(s.startsWith('(') && s.endsWith(')')));
|
|
152
|
+
let url = '/' + segs.join('/');
|
|
153
|
+
url = url.replace(/\/index$/, '') || '/';
|
|
154
|
+
add(url, abs);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return map;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Next App Router layout chain for a screen file: every ancestor layout.* up
|
|
163
|
+
* to analysisRoot. From a screen the user CAN navigate via these (e.g. a
|
|
164
|
+
* sidebar living in the segment layout). */
|
|
165
|
+
function layoutChain(absFile, analysisRoot, absSet) {
|
|
166
|
+
const chain = [];
|
|
167
|
+
let dir = path.dirname(absFile);
|
|
168
|
+
const root = path.resolve(analysisRoot);
|
|
169
|
+
for (let i = 0; i < 24; i++) {
|
|
170
|
+
for (const ext of EXTS) {
|
|
171
|
+
const cand = path.join(dir, 'layout' + ext);
|
|
172
|
+
if (absSet.has(cand)) chain.push(cand);
|
|
173
|
+
}
|
|
174
|
+
if (dir === root || !dir.startsWith(root)) break;
|
|
175
|
+
const parent = path.dirname(dir);
|
|
176
|
+
if (parent === dir) break;
|
|
177
|
+
dir = parent;
|
|
178
|
+
}
|
|
179
|
+
return chain;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Detect the deployed public entry SCREEN file(s) from the filesystem. */
|
|
183
|
+
function findEntryFiles(routeMap, extracted) {
|
|
184
|
+
const entries = new Set();
|
|
185
|
+
const home = routeMap.get('/');
|
|
186
|
+
if (home) entries.add(home);
|
|
187
|
+
return entries;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Union of navTargets found across a set of files' import closures (+ each
|
|
191
|
+
* file's own targets). */
|
|
192
|
+
function collectTargets(files, edges, extracted) {
|
|
193
|
+
const targets = [];
|
|
194
|
+
for (const f of files) {
|
|
195
|
+
for (const dep of closure(f, edges)) {
|
|
196
|
+
const ex = extracted.get(dep);
|
|
197
|
+
if (ex && ex.navTargets) targets.push(...ex.navTargets);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return targets;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function anyAuthSignal(files, edges, extracted) {
|
|
204
|
+
for (const f of files) {
|
|
205
|
+
for (const dep of closure(f, edges)) {
|
|
206
|
+
const ex = extracted.get(dep);
|
|
207
|
+
if (ex && ex.authSignals && ex.authSignals.length) return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A root middleware that redirects unauthenticated users gates the WHOLE app;
|
|
214
|
+
* treat it as a global auth gate (never disable anything → SaaS guard). */
|
|
215
|
+
function hasGlobalAuthGate(included, analysisRoot, extracted) {
|
|
216
|
+
for (const f of included) {
|
|
217
|
+
const rel = toPosix(f.rel);
|
|
218
|
+
if (!/^(?:src\/)?middleware\.(?:ts|js)$/.test(rel)) continue;
|
|
219
|
+
const abs = path.resolve(analysisRoot, f.rel);
|
|
220
|
+
const ex = extracted.get(abs);
|
|
221
|
+
if (ex && ex.authSignals && ex.authSignals.length) return true;
|
|
222
|
+
// Fallback: scan raw content for a login redirect (middleware is small).
|
|
223
|
+
if (/\/(login|signin|sign-in)\b/i.test(f.content) && /redirect|NextResponse/.test(f.content)) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Score every route/action of a manifest by product-surface confidence and
|
|
232
|
+
* compute the conservative default-disabled sets.
|
|
233
|
+
*
|
|
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
|
+
* @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,
|
|
246
|
+
* }}
|
|
247
|
+
*/
|
|
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
|
+
|
|
262
|
+
const { extracted, edges, absSet } = buildGraphFromFiles(included, analysisRoot);
|
|
263
|
+
const routeMap = buildWebRouteMap(included, analysisRoot);
|
|
264
|
+
|
|
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
|
+
const globalAuthGate = hasGlobalAuthGate(included, analysisRoot, extracted);
|
|
275
|
+
|
|
276
|
+
// Entry screen file(s). Without one we cannot judge orphans → disable nothing.
|
|
277
|
+
const entryFiles = findEntryFiles(routeMap, extracted);
|
|
278
|
+
|
|
279
|
+
// SPA route hosts (react-router/Angular/Vue): their links are GLOBAL nav.
|
|
280
|
+
const routeHostFiles = [];
|
|
281
|
+
for (const [abs, ex] of extracted) if (ex.routeHost) routeHostFiles.push(abs);
|
|
282
|
+
|
|
283
|
+
// Is the navigation graph trustworthy? (Enough resolvable internal links that
|
|
284
|
+
// an "orphan" is believable rather than just unparsed dynamic navigation.)
|
|
285
|
+
const allTargets = new Set();
|
|
286
|
+
for (const ex of extracted.values()) {
|
|
287
|
+
for (const t of ex.navTargets || []) {
|
|
288
|
+
const f = routeMap.get(normUrl(t));
|
|
289
|
+
if (f) allTargets.add(normUrl(t));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const trustworthy = allTargets.size >= 2;
|
|
293
|
+
|
|
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
|
+
const reached = new Set(entryFiles);
|
|
300
|
+
const queue = [...entryFiles];
|
|
301
|
+
const seedTargets = (files) => {
|
|
302
|
+
for (const t of collectTargets(files, edges, extracted)) {
|
|
303
|
+
const f = routeMap.get(normUrl(t));
|
|
304
|
+
if (f && !reached.has(f)) {
|
|
305
|
+
reached.add(f);
|
|
306
|
+
queue.push(f);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
// Global navigation from route hosts (SPA) is reachable from anywhere.
|
|
311
|
+
seedTargets(routeHostFiles);
|
|
312
|
+
while (queue.length) {
|
|
313
|
+
const f = queue.shift();
|
|
314
|
+
const shell = [f, ...layoutChain(f, analysisRoot, absSet)];
|
|
315
|
+
seedTargets(shell);
|
|
316
|
+
}
|
|
317
|
+
|
|
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
|
+
const reachableClosure = new Set();
|
|
321
|
+
for (const f of reached) {
|
|
322
|
+
for (const dep of closure(f, edges)) reachableClosure.add(dep);
|
|
323
|
+
for (const lay of layoutChain(f, analysisRoot, absSet)) {
|
|
324
|
+
for (const dep of closure(lay, edges)) reachableClosure.add(dep);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const routes = {};
|
|
329
|
+
const disabledRouteIntents = [];
|
|
330
|
+
const entryUrls = new Set([...entryFiles].map(() => '/'));
|
|
331
|
+
|
|
332
|
+
for (const r of manifest.routes) {
|
|
333
|
+
const intent = r.intent;
|
|
334
|
+
if (!intent) continue;
|
|
335
|
+
if (r.route === 'BACK') {
|
|
336
|
+
routes[intent] = { confidence: 'high', reason: 'reachable' };
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// Prefer the analyzer's evidence file; fall back to mapping the route URL to
|
|
340
|
+
// a screen file (web route maps), since route evidence is optional in web
|
|
341
|
+
// mode while action evidence is mandatory.
|
|
342
|
+
const ef = evAbs((routeEv[intent] || {}).file) || routeMap.get(normUrl(r.route)) || null;
|
|
343
|
+
const isEntry = (ef && entryFiles.has(ef)) || entryUrls.has(normUrl(r.route));
|
|
344
|
+
const reachable = ef && reached.has(ef);
|
|
345
|
+
|
|
346
|
+
if (reachable || isEntry) {
|
|
347
|
+
routes[intent] = { confidence: 'high', reason: 'reachable' };
|
|
348
|
+
} else if (!canScore || !ef) {
|
|
349
|
+
routes[intent] = { confidence: 'high', reason: 'unknown' };
|
|
350
|
+
} else if (anyAuthSignal([ef, ...layoutChain(ef, analysisRoot, absSet)], edges, extracted)) {
|
|
351
|
+
routes[intent] = { confidence: 'high', reason: 'behind-auth' };
|
|
352
|
+
} else {
|
|
353
|
+
routes[intent] = { confidence: 'low', reason: 'orphan' };
|
|
354
|
+
disabledRouteIntents.push(intent);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const actions = {};
|
|
359
|
+
const disabledActionIntents = [];
|
|
360
|
+
for (const a of manifest.actions || []) {
|
|
361
|
+
const intent = a.intent;
|
|
362
|
+
if (!intent) continue;
|
|
363
|
+
const ef = evAbs((actionEv[intent] || {}).file);
|
|
364
|
+
const onSurface = ef && reachableClosure.has(ef);
|
|
365
|
+
|
|
366
|
+
if (onSurface) {
|
|
367
|
+
actions[intent] = { confidence: 'high', reason: 'reachable' };
|
|
368
|
+
} else if (!canScore || !ef) {
|
|
369
|
+
actions[intent] = { confidence: 'high', reason: 'unknown' };
|
|
370
|
+
} else if (anyAuthSignal([ef, ...layoutChain(ef, analysisRoot, absSet)], edges, extracted)) {
|
|
371
|
+
actions[intent] = { confidence: 'high', reason: 'behind-auth' };
|
|
372
|
+
} else {
|
|
373
|
+
actions[intent] = { confidence: 'low', reason: 'orphan' };
|
|
374
|
+
disabledActionIntents.push(intent);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
routes,
|
|
380
|
+
actions,
|
|
381
|
+
disabledRouteIntents,
|
|
382
|
+
disabledActionIntents,
|
|
383
|
+
stats: {
|
|
384
|
+
applied: canScore,
|
|
385
|
+
reason: canScore
|
|
386
|
+
? 'scored'
|
|
387
|
+
: globalAuthGate
|
|
388
|
+
? 'global-auth-gate'
|
|
389
|
+
: entryFiles.size === 0
|
|
390
|
+
? 'no-entry'
|
|
391
|
+
: !trustworthy
|
|
392
|
+
? 'sparse-graph'
|
|
393
|
+
: 'unknown',
|
|
394
|
+
entryCount: entryFiles.size,
|
|
395
|
+
reachableScreens: reached.size,
|
|
396
|
+
resolvableTargets: allTargets.size,
|
|
397
|
+
trustworthy,
|
|
398
|
+
globalAuthGate,
|
|
399
|
+
routesDisabled: disabledRouteIntents.length,
|
|
400
|
+
actionsDisabled: disabledActionIntents.length,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = { scoreSurface, normUrl, buildWebRouteMap, buildGraphFromFiles };
|