@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.7",
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
- // Reuse the monorepo's TypeScript — real parser, real AST.
24
- const ts = require(path.join(__dirname, '../../../node_modules/typescript'));
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
@@ -157,4 +157,4 @@ function mapScreens(routes, graph) {
157
157
  return { screens, stores };
158
158
  }
159
159
 
160
- module.exports = { buildGraph, mapScreens };
160
+ module.exports = { buildGraph, mapScreens, closure };
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
- const tick = () => {
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} ${label}… ${dim}${sec}s${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
- tick();
176
- const id = setInterval(tick, 140);
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
- clearInterval(id);
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 with AI',
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
- if (process.argv.includes('--publish')) {
377
- loadAppEnv(analysisRoot);
378
- await publishManifest(manifest, {
379
- ...analysisMeta,
380
- generatedAt: new Date().toISOString(),
381
- routesIncluded: manifest.routes.length,
382
- actionsIncluded: manifest.actions.length,
383
- }, analysisToken);
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 handlerResult = await wireHandlers(analysisRoot, {
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
- reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
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 (process.argv.includes('--publish') && !process.argv.includes('--no-write-env')) {
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
- logUser('Published to your project');
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 };