@fiodos/cli 0.1.0

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/src/ast.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Layer 1b — per-file AST extraction using the real TypeScript compiler
3
+ * (no regexes). For each source file we extract:
4
+ *
5
+ * - handlers: function declarations / consts named handle* | on*
6
+ * - navCalls: router.push / router.replace / router.navigate / router.back
7
+ * with their literal or object destinations
8
+ * - alerts: Alert.alert(title, message, buttons) + whether any button is
9
+ * style:'destructive' and the texts involved
10
+ * - storeActions: Zustand create()(…) — function-valued properties of the
11
+ * store object (the store's action surface)
12
+ * - buttons: JSX elements with a title/label prop and an onPress handler
13
+ * - textInputs: JSX TextInput-ish elements (signals form/typed input)
14
+ * - imports: module specifiers + named/default bindings
15
+ * - linkingCalls: Linking.openURL targets (tel:, maps, web)
16
+ *
17
+ * Everything returned here has provenance "static".
18
+ */
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
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'));
25
+
26
+ function parseFile(filePath) {
27
+ const text = fs.readFileSync(filePath, 'utf8');
28
+ return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
29
+ }
30
+
31
+ function literalText(node) {
32
+ if (!node) return null;
33
+ if (ts.isStringLiteralLike(node)) return node.text;
34
+ if (ts.isTemplateExpression(node)) {
35
+ // `/exercise-detail?id=${x}` → '/exercise-detail?id=${…}'
36
+ let out = node.head.text;
37
+ for (const span of node.templateSpans) out += '${…}' + span.literal.text;
38
+ return out;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function nameOfFunctionLike(node) {
44
+ if (ts.isFunctionDeclaration(node) && node.name) return node.name.text;
45
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
46
+ const init = node.initializer;
47
+ if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) return node.name.text;
48
+ // useCallback(fn, deps)
49
+ if (init && ts.isCallExpression(init) && init.arguments.length > 0) {
50
+ const a0 = init.arguments[0];
51
+ if (ts.isArrowFunction(a0) || ts.isFunctionExpression(a0)) return node.name.text;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ const HANDLER_RE = /^(handle|on)[A-Z]/;
58
+
59
+ function extractFromFile(filePath, appRoot) {
60
+ const sf = parseFile(filePath);
61
+ const rel = path.relative(appRoot, filePath);
62
+
63
+ const out = {
64
+ file: rel,
65
+ handlers: [],
66
+ navCalls: [],
67
+ alerts: [],
68
+ storeActions: [],
69
+ storeStateKeys: [],
70
+ storeName: null,
71
+ buttons: [],
72
+ textInputs: [],
73
+ imports: [],
74
+ linkingCalls: [],
75
+ defaultExportName: null,
76
+ };
77
+
78
+ function jsxAttr(attrs, names) {
79
+ for (const attr of attrs.properties) {
80
+ if (ts.isJsxAttribute(attr) && names.includes(attr.name.getText())) {
81
+ if (attr.initializer && ts.isStringLiteral(attr.initializer)) return attr.initializer.text;
82
+ if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
83
+ const lit = literalText(attr.initializer.expression);
84
+ if (lit) return lit;
85
+ return attr.initializer.expression.getText().slice(0, 80);
86
+ }
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function jsxAttrHandlerName(attrs, names) {
93
+ for (const attr of attrs.properties) {
94
+ if (ts.isJsxAttribute(attr) && names.includes(attr.name.getText())) {
95
+ const init = attr.initializer;
96
+ if (init && ts.isJsxExpression(init) && init.expression) {
97
+ const e = init.expression;
98
+ if (ts.isIdentifier(e)) return e.text;
99
+ if (ts.isArrowFunction(e) && ts.isCallExpression(e.body)) {
100
+ return e.body.expression.getText();
101
+ }
102
+ return e.getText().slice(0, 60);
103
+ }
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function visit(node) {
110
+ // imports
111
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
112
+ const names = [];
113
+ const clause = node.importClause;
114
+ if (clause) {
115
+ if (clause.name) names.push(clause.name.text);
116
+ if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
117
+ for (const el of clause.namedBindings.elements) names.push(el.name.text);
118
+ }
119
+ }
120
+ out.imports.push({ from: node.moduleSpecifier.text, names });
121
+ }
122
+
123
+ // default export name (screen component)
124
+ if (ts.isExportAssignment(node) && ts.isIdentifier(node.expression)) {
125
+ out.defaultExportName = node.expression.text;
126
+ }
127
+ if (ts.isFunctionDeclaration(node) && node.name &&
128
+ node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
129
+ node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) {
130
+ out.defaultExportName = node.name.text;
131
+ }
132
+
133
+ // handler functions
134
+ const fnName = nameOfFunctionLike(node);
135
+ if (fnName && HANDLER_RE.test(fnName)) {
136
+ out.handlers.push({ name: fnName, file: rel });
137
+ }
138
+
139
+ // call expressions: router.*, Alert.alert, Linking.openURL, zustand create
140
+ if (ts.isCallExpression(node)) {
141
+ const exprText = node.expression.getText();
142
+
143
+ // router.push / replace / navigate / back (covers `router.` and bare `push(` from useRouter destructuring is rare)
144
+ const navMatch = exprText.match(/^(router|navigation)\.(push|replace|navigate|back|goBack)$/);
145
+ if (navMatch) {
146
+ const verb = navMatch[2];
147
+ let target = null;
148
+ const a0 = node.arguments[0];
149
+ if (a0) {
150
+ target = literalText(a0);
151
+ if (!target && ts.isObjectLiteralExpression(a0)) {
152
+ for (const p of a0.properties) {
153
+ if (ts.isPropertyAssignment(p) && p.name.getText() === 'pathname') {
154
+ target = literalText(p.initializer) || p.initializer.getText();
155
+ }
156
+ }
157
+ }
158
+ }
159
+ out.navCalls.push({ verb, target, file: rel });
160
+ }
161
+
162
+ // Alert.alert(title, message?, buttons?)
163
+ if (exprText === 'Alert.alert') {
164
+ const title = literalText(node.arguments[0]) || '';
165
+ const message = literalText(node.arguments[1]) || '';
166
+ let destructive = false;
167
+ const buttonTexts = [];
168
+ const a2 = node.arguments[2];
169
+ if (a2 && ts.isArrayLiteralExpression(a2)) {
170
+ for (const el of a2.elements) {
171
+ if (!ts.isObjectLiteralExpression(el)) continue;
172
+ for (const p of el.properties) {
173
+ if (!ts.isPropertyAssignment(p)) continue;
174
+ const key = p.name.getText();
175
+ if (key === 'style' && literalText(p.initializer) === 'destructive') destructive = true;
176
+ if (key === 'text') {
177
+ const t = literalText(p.initializer);
178
+ if (t) buttonTexts.push(t);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ out.alerts.push({ title, message, destructive, buttonTexts, file: rel });
184
+ }
185
+
186
+ // Linking.openURL(...)
187
+ if (/^Linking\.(openURL|canOpenURL)$/.test(exprText) && node.arguments[0]) {
188
+ const t = literalText(node.arguments[0]) || node.arguments[0].getText().slice(0, 60);
189
+ out.linkingCalls.push({ target: t, file: rel });
190
+ }
191
+
192
+ // Zustand: create<T>()(persist((set) => ({...}))) or create((set) => ({...}))
193
+ if (/^create(<.*>)?$/.test(exprText.split('(')[0]) && exprText.startsWith('create')) {
194
+ const storeObj = findStoreObject(node);
195
+ if (storeObj) {
196
+ for (const p of storeObj.properties) {
197
+ if (!ts.isPropertyAssignment(p) && !ts.isShorthandPropertyAssignment(p) && !ts.isMethodDeclaration(p)) continue;
198
+ const key = p.name?.getText();
199
+ if (!key) continue;
200
+ const val = ts.isPropertyAssignment(p) ? p.initializer : null;
201
+ const isFn = ts.isMethodDeclaration(p) ||
202
+ (val && (ts.isArrowFunction(val) || ts.isFunctionExpression(val)));
203
+ if (isFn) out.storeActions.push({ name: key, file: rel });
204
+ else out.storeStateKeys.push(key);
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ // exported const useXxxStore = create(...)
211
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) &&
212
+ /use[A-Z].*Store/.test(node.name.text)) {
213
+ out.storeName = node.name.text;
214
+ }
215
+
216
+ // JSX buttons + inputs
217
+ if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
218
+ const tag = node.tagName.getText();
219
+ const attrs = node.attributes;
220
+ if (/Button/i.test(tag) || tag === 'TouchableOpacity' || tag === 'Pressable') {
221
+ const title = jsxAttr(attrs, ['title', 'label', 'accessibilityLabel']);
222
+ const handler = jsxAttrHandlerName(attrs, ['onPress']);
223
+ if (title || handler) out.buttons.push({ tag, title, handler, file: rel });
224
+ }
225
+ if (/TextInput|Input/.test(tag)) {
226
+ const placeholder = jsxAttr(attrs, ['placeholder']);
227
+ const onChange = jsxAttrHandlerName(attrs, ['onChangeText', 'onChange']);
228
+ out.textInputs.push({ tag, placeholder, onChange, file: rel });
229
+ }
230
+ }
231
+
232
+ ts.forEachChild(node, visit);
233
+ }
234
+
235
+ // Finds the object literal returned by the store factory ((set) => ({...}))
236
+ // possibly wrapped in persist()/devtools().
237
+ function findStoreObject(callNode) {
238
+ let found = null;
239
+ function dig(n, depth) {
240
+ if (found || depth > 8) return;
241
+ if (ts.isArrowFunction(n)) {
242
+ let body = n.body;
243
+ if (ts.isParenthesizedExpression(body)) body = body.expression;
244
+ if (ts.isObjectLiteralExpression(body)) {
245
+ // Heuristic: the store object has at least one function property
246
+ const hasFn = body.properties.some(
247
+ (p) => ts.isPropertyAssignment(p) &&
248
+ (ts.isArrowFunction(p.initializer) || ts.isFunctionExpression(p.initializer)),
249
+ );
250
+ if (hasFn) { found = body; return; }
251
+ }
252
+ }
253
+ ts.forEachChild(n, (c) => dig(c, depth + 1));
254
+ }
255
+ dig(callNode, 0);
256
+ return found;
257
+ }
258
+
259
+ visit(sf);
260
+ return out;
261
+ }
262
+
263
+ module.exports = { extractFromFile };
package/src/collect.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Source collector for the AI-first engine.
3
+ *
4
+ * Cheap, rule-based file selection — NOT analysis. It cannot "fail to zero":
5
+ * every code file is included unless it is objectively irrelevant (deps,
6
+ * native projects, assets, tests, build configs). When the app exceeds the
7
+ * input budget, files are prioritized by tier (screens/state/services first,
8
+ * generic UI last) and the omitted ones are reported so the model can declare
9
+ * what it did not see.
10
+ */
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ /**
17
+ * Per-platform collection profile: which source extensions are code, and which
18
+ * directories to skip. Native (Phase 1) reuses the SAME budget + tiering + AI
19
+ * pipeline as web/mobile — only the file selection differs by language.
20
+ */
21
+ const PROFILES = {
22
+ web: {
23
+ exts: ['.ts', '.tsx', '.js', '.jsx', '.vue', '.svelte'],
24
+ skipDirs: ['node_modules', 'assets', '.git', 'dist', 'build', '.expo', 'android', 'ios', 'coverage', '.next', 'web-build'],
25
+ },
26
+ mobile: {
27
+ exts: ['.ts', '.tsx', '.js', '.jsx'],
28
+ skipDirs: ['node_modules', 'assets', '.git', 'dist', 'build', '.expo', 'android', 'ios', 'coverage', '.next', 'web-build'],
29
+ },
30
+ // Flutter: analyze lib/ (Dart); skip the native host wrappers + build output.
31
+ flutter: {
32
+ exts: ['.dart'],
33
+ skipDirs: ['.git', '.dart_tool', 'build', 'android', 'ios', 'web', 'linux', 'macos', 'windows', 'test', 'integration_test'],
34
+ },
35
+ // iOS: analyze Swift sources; skip SwiftPM/Xcode build output.
36
+ ios: {
37
+ exts: ['.swift'],
38
+ skipDirs: ['.git', '.build', 'build', 'DerivedData', 'Pods', 'Carthage', 'Tests'],
39
+ },
40
+ // Android: analyze Kotlin sources; skip Gradle build output.
41
+ android: {
42
+ exts: ['.kt'],
43
+ skipDirs: ['.git', '.gradle', 'build', '.idea'],
44
+ },
45
+ };
46
+
47
+ const SKIP_FILES = /\.(d\.ts|test\.|spec\.)|babel\.config|metro\.config|tailwind\.config|postcss\.config|jest\.config|eslint/;
48
+ const SKIP_NATIVE_FILES = /\.(g\.dart|freezed\.dart|gr\.dart|mocks\.dart)$|_test\.(dart|kt|swift)$|Test\.kt$|Tests\.swift$/;
49
+
50
+ /** Rough chars→tokens for budgeting (code averages ~3.6 chars/token). */
51
+ const CHARS_PER_TOKEN = 3.6;
52
+
53
+ /** Lower tier = higher priority when the budget forces choices. Generalized so
54
+ * it ranks screens/state/services first across web, RN AND native naming. */
55
+ function tierFor(rel) {
56
+ const p = rel.replace(/\\/g, '/');
57
+ if (/^(src\/)?app\//.test(p)) return 0; // expo-router / Next screens
58
+ if (/(^|\/)(screens?|pages?|views?|routes?|activities|fragments)\//i.test(p)) return 0;
59
+ if (/(screen|page|view|activity|fragment)\.(dart|kt|swift)$/i.test(p)) return 0;
60
+ if (/(^|\/)(state|store|stores|context|contexts|redux|providers?|blocs?|cubits?|viewmodels?)\//i.test(p)) return 1;
61
+ if (/(viewmodel|notifier|bloc|cubit|store|controller)\.(dart|kt|swift)$/i.test(p)) return 1;
62
+ if (/(^|\/)(services?|api|db|data|domain|agent|handlers?|actions?|lib|utils|repositories|repository|usecases?)\//i.test(p)) return 1;
63
+ if (/(^|\/)(hooks)\//.test(p)) return 2;
64
+ return 3; // generic components / ui
65
+ }
66
+
67
+ function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' } = {}) {
68
+ const profile = PROFILES[platform] || PROFILES.mobile;
69
+ const codeExt = new Set(profile.exts);
70
+ const skipDirs = new Set(profile.skipDirs);
71
+ const isNative = platform === 'flutter' || platform === 'ios' || platform === 'android';
72
+
73
+ const files = [];
74
+ const walk = (dir) => {
75
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
76
+ if (entry.isDirectory()) {
77
+ if (!skipDirs.has(entry.name)) walk(path.join(dir, entry.name));
78
+ continue;
79
+ }
80
+ const full = path.join(dir, entry.name);
81
+ const rel = path.relative(appRoot, full);
82
+ if (!codeExt.has(path.extname(entry.name))) continue;
83
+ if (SKIP_FILES.test(rel)) continue;
84
+ if (isNative && SKIP_NATIVE_FILES.test(rel)) continue;
85
+ const content = fs.readFileSync(full, 'utf8');
86
+ files.push({ rel, content, tier: tierFor(rel), chars: content.length });
87
+ }
88
+ };
89
+ walk(appRoot);
90
+
91
+ // Stable order: tier, then path (deterministic prompts → reproducible runs).
92
+ files.sort((a, b) => a.tier - b.tier || a.rel.localeCompare(b.rel));
93
+
94
+ const budgetChars = maxInputTokens * CHARS_PER_TOKEN;
95
+ const included = [];
96
+ const omitted = [];
97
+ let used = 0;
98
+ for (const f of files) {
99
+ if (used + f.chars <= budgetChars) {
100
+ included.push(f);
101
+ used += f.chars;
102
+ } else {
103
+ omitted.push({ rel: f.rel, chars: f.chars, tier: f.tier });
104
+ }
105
+ }
106
+
107
+ return {
108
+ included,
109
+ omitted,
110
+ totalChars: used,
111
+ estimatedTokens: Math.round(used / CHARS_PER_TOKEN),
112
+ };
113
+ }
114
+
115
+ module.exports = { collectFiles };
package/src/graph.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Layer 2 — dependency graph and state mapping.
3
+ *
4
+ * - Resolves imports between project files (including tsconfig-style '@/'
5
+ * aliases) to know which modules each screen pulls in (transitively).
6
+ * - Maps stores (Zustand) to the screens that consume them.
7
+ * - Builds, per screen, the consolidated action surface: own handlers +
8
+ * store actions reachable from it, plus the evidence (alerts, buttons,
9
+ * nav calls) needed downstream.
10
+ *
11
+ * Everything here is still provenance "static".
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { extractFromFile } = require('./ast');
18
+
19
+ const EXTS = ['.tsx', '.ts', '.jsx', '.js'];
20
+
21
+ function resolveImport(fromFile, spec, appRoot, srcRoot) {
22
+ let base = null;
23
+ if (spec.startsWith('.')) base = path.resolve(path.dirname(fromFile), spec);
24
+ else if (spec.startsWith('@/')) base = path.join(srcRoot, spec.slice(2));
25
+ else return null; // node_modules — out of scope
26
+ for (const ext of ['', ...EXTS]) {
27
+ const p = base + ext;
28
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) return p;
29
+ }
30
+ for (const ext of EXTS) {
31
+ const p = path.join(base, 'index' + ext);
32
+ if (fs.existsSync(p)) return p;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function findSrcRoot(appRoot) {
38
+ const src = path.join(appRoot, 'src');
39
+ return fs.existsSync(src) ? src : appRoot;
40
+ }
41
+
42
+ /** Parses every project source file once; builds extraction map + import graph. */
43
+ function buildGraph(appRoot) {
44
+ const srcRoot = findSrcRoot(appRoot);
45
+ const files = [];
46
+ (function walk(dir) {
47
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
48
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
49
+ const full = path.join(dir, e.name);
50
+ if (e.isDirectory()) walk(full);
51
+ else if (EXTS.includes(path.extname(e.name))) files.push(full);
52
+ }
53
+ })(srcRoot);
54
+
55
+ const extracted = new Map(); // absPath -> extraction
56
+ const edges = new Map(); // absPath -> Set(absPath)
57
+
58
+ for (const f of files) {
59
+ let ex;
60
+ try {
61
+ ex = extractFromFile(f, appRoot);
62
+ } catch (err) {
63
+ ex = { file: path.relative(appRoot, f), parseError: String(err), handlers: [], navCalls: [], alerts: [], storeActions: [], storeStateKeys: [], buttons: [], textInputs: [], imports: [], linkingCalls: [] };
64
+ }
65
+ extracted.set(f, ex);
66
+ const deps = new Set();
67
+ for (const imp of ex.imports || []) {
68
+ const resolved = resolveImport(f, imp.from, appRoot, srcRoot);
69
+ if (resolved) deps.add(resolved);
70
+ }
71
+ edges.set(f, deps);
72
+ }
73
+
74
+ return { files, extracted, edges, srcRoot };
75
+ }
76
+
77
+ /** Transitive dependency closure of a file (bounded). */
78
+ function closure(file, edges, maxDepth = 4) {
79
+ const seen = new Set([file]);
80
+ let frontier = [file];
81
+ for (let d = 0; d < maxDepth; d++) {
82
+ const next = [];
83
+ for (const f of frontier) {
84
+ for (const dep of edges.get(f) || []) {
85
+ if (!seen.has(dep)) {
86
+ seen.add(dep);
87
+ next.push(dep);
88
+ }
89
+ }
90
+ }
91
+ frontier = next;
92
+ }
93
+ return seen;
94
+ }
95
+
96
+ /**
97
+ * For each screen (route file), aggregates the action surface visible from it.
98
+ */
99
+ function mapScreens(routes, graph) {
100
+ const { extracted, edges } = graph;
101
+
102
+ // global store registry: file -> {storeName, actions, stateKeys}
103
+ const stores = [];
104
+ for (const [f, ex] of extracted) {
105
+ if (ex.storeActions && ex.storeActions.length > 0) {
106
+ stores.push({
107
+ file: ex.file,
108
+ absFile: f,
109
+ storeName: ex.storeName || path.basename(f).replace(/\..+$/, ''),
110
+ actions: ex.storeActions.map((a) => a.name),
111
+ stateKeys: ex.storeStateKeys,
112
+ });
113
+ }
114
+ }
115
+
116
+ const screens = routes.map((route) => {
117
+ const ex = extracted.get(route.filePath) || {};
118
+ const deps = closure(route.filePath, edges);
119
+
120
+ const usedStores = stores.filter((s) => deps.has(s.absFile));
121
+ const depHandlers = [];
122
+ const depAlerts = [];
123
+ const depButtons = [];
124
+ const depNavCalls = [];
125
+ const depLinking = [];
126
+ const depTextInputs = [];
127
+ for (const d of deps) {
128
+ if (d === route.filePath) continue;
129
+ const dex = extracted.get(d);
130
+ if (!dex) continue;
131
+ depHandlers.push(...(dex.handlers || []));
132
+ depAlerts.push(...(dex.alerts || []));
133
+ depButtons.push(...(dex.buttons || []));
134
+ depNavCalls.push(...(dex.navCalls || []));
135
+ depLinking.push(...(dex.linkingCalls || []));
136
+ depTextInputs.push(...(dex.textInputs || []));
137
+ }
138
+
139
+ return {
140
+ route,
141
+ ownHandlers: ex.handlers || [],
142
+ ownAlerts: ex.alerts || [],
143
+ ownButtons: ex.buttons || [],
144
+ ownNavCalls: ex.navCalls || [],
145
+ ownTextInputs: (ex.textInputs || []).concat(depTextInputs),
146
+ ownLinking: ex.linkingCalls || [],
147
+ depHandlers,
148
+ depAlerts,
149
+ depButtons,
150
+ depNavCalls,
151
+ depLinking,
152
+ usedStores: usedStores.map((s) => ({ storeName: s.storeName, file: s.file, actions: s.actions })),
153
+ defaultExportName: ex.defaultExportName || null,
154
+ };
155
+ });
156
+
157
+ return { screens, stores };
158
+ }
159
+
160
+ module.exports = { buildGraph, mapScreens };