@fiodos/cli 0.1.4 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * resolveAnalysisRoot — pick the folder the CLI should actually analyze.
3
+ *
4
+ * Monorepos often contain BOTH a web app (Next/Vite) and a mobile app
5
+ * (Expo/RN) under one git root. Running `analyze .` from the repo root makes
6
+ * the AI read mobile screens too, so the web orb gets actions that do not
7
+ * exist on the website. When we can identify a single sub-app for the target
8
+ * platform, we scope collection/wiring to THAT folder only.
9
+ */
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const SKIP_SCAN = new Set([
16
+ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'android', 'ios',
17
+ '.expo', 'web-build', '.venv', 'vendor', 'target', '.gradle', '.dart_tool',
18
+ ]);
19
+
20
+ /** Conventional subfolder names — checked first (higher confidence). */
21
+ const WEB_ROOT_NAMES = ['frontend', 'web', 'www', 'client', 'apps/web', 'packages/web'];
22
+ const MOBILE_ROOT_NAMES = ['mobile', 'app', 'apps/mobile', 'apps/app', 'packages/mobile', 'native'];
23
+
24
+ function readDeps(pkgPath) {
25
+ try {
26
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
27
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function hasWebDeps(deps) {
34
+ if (!deps) return false;
35
+ return Boolean(
36
+ deps.next || deps.vite || deps['@vitejs/plugin-react'] || deps['react-scripts'] ||
37
+ deps['@angular/core'] || deps['@angular-devkit/build-angular'] ||
38
+ deps['@sveltejs/kit'] || deps.svelte || deps.vue || deps['@vue/cli-service'],
39
+ );
40
+ }
41
+
42
+ function hasMobileDeps(deps) {
43
+ if (!deps) return false;
44
+ return Boolean(deps.expo || deps['expo-router'] || deps['react-native']);
45
+ }
46
+
47
+ function platformDepsMatch(deps, platform) {
48
+ if (!deps) return false;
49
+ if (platform === 'web') return hasWebDeps(deps) && !hasMobileDeps(deps);
50
+ if (platform === 'mobile') return hasMobileDeps(deps) && !hasWebDeps(deps);
51
+ return false;
52
+ }
53
+
54
+ function tryCandidate(appRoot, rel, platform, score) {
55
+ const dir = path.join(appRoot, rel);
56
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null;
57
+ const deps = readDeps(path.join(dir, 'package.json'));
58
+ if (!platformDepsMatch(deps, platform)) return null;
59
+ return { root: dir, rel, score, reason: rel };
60
+ }
61
+
62
+ /**
63
+ * @returns {{ root: string, scoped: boolean, reason: string }}
64
+ */
65
+ function resolveAnalysisRoot(appRoot, platform) {
66
+ const abs = path.resolve(appRoot);
67
+ const rootDeps = readDeps(path.join(abs, 'package.json'));
68
+
69
+ // Root IS the target app (single-package repo).
70
+ if (platformDepsMatch(rootDeps, platform)) {
71
+ return { root: abs, scoped: false, reason: 'root package.json' };
72
+ }
73
+
74
+ const candidates = [];
75
+
76
+ const preferred = platform === 'web' ? WEB_ROOT_NAMES : MOBILE_ROOT_NAMES;
77
+ for (const name of preferred) {
78
+ const hit = tryCandidate(abs, name, platform, 20);
79
+ if (hit) candidates.push(hit);
80
+ }
81
+
82
+ // One level of immediate subdirs (frontend/, mobile/, apps/*, packages/*).
83
+ let entries = [];
84
+ try {
85
+ entries = fs.readdirSync(abs, { withFileTypes: true });
86
+ } catch {
87
+ return { root: abs, scoped: false, reason: 'unreadable root' };
88
+ }
89
+
90
+ for (const entry of entries) {
91
+ if (!entry.isDirectory() || SKIP_SCAN.has(entry.name)) continue;
92
+ const hit = tryCandidate(abs, entry.name, platform, 10);
93
+ if (hit) candidates.push(hit);
94
+
95
+ // apps/web, packages/web, etc.
96
+ const nestedRoot = path.join(abs, entry.name);
97
+ let nested = [];
98
+ try {
99
+ nested = fs.readdirSync(nestedRoot, { withFileTypes: true });
100
+ } catch {
101
+ continue;
102
+ }
103
+ for (const sub of nested) {
104
+ if (!sub.isDirectory() || SKIP_SCAN.has(sub.name)) continue;
105
+ const rel = path.join(entry.name, sub.name);
106
+ const deep = tryCandidate(abs, rel, platform, 8);
107
+ if (deep) candidates.push(deep);
108
+ }
109
+ }
110
+
111
+ if (!candidates.length) {
112
+ return { root: abs, scoped: false, reason: 'no sub-app detected' };
113
+ }
114
+
115
+ // Prefer higher score, then shorter path (closer to root).
116
+ candidates.sort((a, b) => b.score - a.score || a.rel.length - b.rel.length);
117
+ const best = candidates[0];
118
+ return {
119
+ root: best.root,
120
+ scoped: best.root !== abs,
121
+ reason: best.reason,
122
+ };
123
+ }
124
+
125
+ module.exports = { resolveAnalysisRoot, hasWebDeps, hasMobileDeps };
@@ -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 };