lynkeus 0.2.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.
Files changed (217) hide show
  1. package/lib/__tests__/args.test.d.ts +1 -0
  2. package/lib/__tests__/args.test.js +69 -0
  3. package/lib/__tests__/check.test.d.ts +1 -0
  4. package/lib/__tests__/check.test.js +60 -0
  5. package/lib/__tests__/init.test.d.ts +1 -0
  6. package/lib/__tests__/init.test.js +42 -0
  7. package/lib/__tests__/record.test.d.ts +1 -0
  8. package/lib/__tests__/record.test.js +44 -0
  9. package/lib/__tests__/snapshot.test.d.ts +1 -0
  10. package/lib/__tests__/snapshot.test.js +47 -0
  11. package/lib/__tests__/targets.test.d.ts +1 -0
  12. package/lib/__tests__/targets.test.js +127 -0
  13. package/lib/args.d.ts +30 -0
  14. package/lib/args.js +86 -0
  15. package/lib/cases/__tests__/format.test.d.ts +1 -0
  16. package/lib/cases/__tests__/format.test.js +61 -0
  17. package/lib/cases/__tests__/runner.test.d.ts +1 -0
  18. package/lib/cases/__tests__/runner.test.js +188 -0
  19. package/lib/cases/fixtures.d.ts +13 -0
  20. package/lib/cases/fixtures.js +100 -0
  21. package/lib/cases/format.d.ts +37 -0
  22. package/lib/cases/format.js +169 -0
  23. package/lib/cases/runner.d.ts +61 -0
  24. package/lib/cases/runner.js +590 -0
  25. package/lib/cases/templates.d.ts +20 -0
  26. package/lib/cases/templates.js +156 -0
  27. package/lib/cli.d.ts +6 -0
  28. package/lib/cli.js +173 -0
  29. package/lib/commands/cases.d.ts +1 -0
  30. package/lib/commands/cases.js +179 -0
  31. package/lib/commands/check.d.ts +36 -0
  32. package/lib/commands/check.js +67 -0
  33. package/lib/commands/crawl/return.d.ts +20 -0
  34. package/lib/commands/crawl/return.js +125 -0
  35. package/lib/commands/crawl/state.d.ts +30 -0
  36. package/lib/commands/crawl/state.js +56 -0
  37. package/lib/commands/crawl.d.ts +67 -0
  38. package/lib/commands/crawl.js +213 -0
  39. package/lib/commands/device.d.ts +10 -0
  40. package/lib/commands/device.js +122 -0
  41. package/lib/commands/doctor.d.ts +3 -0
  42. package/lib/commands/doctor.js +240 -0
  43. package/lib/commands/drive.d.ts +1 -0
  44. package/lib/commands/drive.js +410 -0
  45. package/lib/commands/flake.d.ts +14 -0
  46. package/lib/commands/flake.js +42 -0
  47. package/lib/commands/headless.d.ts +1 -0
  48. package/lib/commands/headless.js +127 -0
  49. package/lib/commands/init.d.ts +13 -0
  50. package/lib/commands/init.js +85 -0
  51. package/lib/commands/map.d.ts +2 -0
  52. package/lib/commands/map.js +297 -0
  53. package/lib/commands/os.d.ts +1 -0
  54. package/lib/commands/os.js +193 -0
  55. package/lib/commands/overlay.d.ts +9 -0
  56. package/lib/commands/overlay.js +12 -0
  57. package/lib/commands/record.d.ts +5 -0
  58. package/lib/commands/record.js +55 -0
  59. package/lib/commands/runs.d.ts +1 -0
  60. package/lib/commands/runs.js +452 -0
  61. package/lib/commands/smoke.d.ts +28 -0
  62. package/lib/commands/smoke.js +59 -0
  63. package/lib/commands/snapshot.d.ts +27 -0
  64. package/lib/commands/snapshot.js +69 -0
  65. package/lib/config.d.ts +72 -0
  66. package/lib/config.js +44 -0
  67. package/lib/device/devices.d.ts +11 -0
  68. package/lib/device/devices.js +33 -0
  69. package/lib/device/driver.d.ts +53 -0
  70. package/lib/device/driver.js +169 -0
  71. package/lib/device/platform.d.ts +7 -0
  72. package/lib/device/platform.js +59 -0
  73. package/lib/files.d.ts +7 -0
  74. package/lib/files.js +29 -0
  75. package/lib/flow/artifacts.d.ts +19 -0
  76. package/lib/flow/artifacts.js +49 -0
  77. package/lib/flow/runner.d.ts +13 -0
  78. package/lib/flow/runner.js +169 -0
  79. package/lib/flow/types.d.ts +111 -0
  80. package/lib/flow/types.js +1 -0
  81. package/lib/index.d.ts +18 -0
  82. package/lib/index.js +11 -0
  83. package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
  84. package/lib/knowledge/__tests__/edges.test.js +25 -0
  85. package/lib/knowledge/__tests__/events.test.d.ts +1 -0
  86. package/lib/knowledge/__tests__/events.test.js +39 -0
  87. package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
  88. package/lib/knowledge/__tests__/extract.test.js +69 -0
  89. package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
  90. package/lib/knowledge/__tests__/frontier.test.js +57 -0
  91. package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
  92. package/lib/knowledge/__tests__/funnels.test.js +40 -0
  93. package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
  94. package/lib/knowledge/__tests__/lint.test.js +53 -0
  95. package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
  96. package/lib/knowledge/__tests__/perf.test.js +34 -0
  97. package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
  98. package/lib/knowledge/__tests__/plan.test.js +35 -0
  99. package/lib/knowledge/__tests__/query.test.d.ts +1 -0
  100. package/lib/knowledge/__tests__/query.test.js +55 -0
  101. package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
  102. package/lib/knowledge/__tests__/screens.test.js +30 -0
  103. package/lib/knowledge/__tests__/why.test.d.ts +1 -0
  104. package/lib/knowledge/__tests__/why.test.js +28 -0
  105. package/lib/knowledge/edges.d.ts +30 -0
  106. package/lib/knowledge/edges.js +68 -0
  107. package/lib/knowledge/errors.d.ts +8 -0
  108. package/lib/knowledge/errors.js +62 -0
  109. package/lib/knowledge/events.d.ts +23 -0
  110. package/lib/knowledge/events.js +51 -0
  111. package/lib/knowledge/extract.d.ts +26 -0
  112. package/lib/knowledge/extract.js +365 -0
  113. package/lib/knowledge/frontier.d.ts +26 -0
  114. package/lib/knowledge/frontier.js +60 -0
  115. package/lib/knowledge/funnels.d.ts +46 -0
  116. package/lib/knowledge/funnels.js +103 -0
  117. package/lib/knowledge/lint.d.ts +16 -0
  118. package/lib/knowledge/lint.js +89 -0
  119. package/lib/knowledge/perf.d.ts +32 -0
  120. package/lib/knowledge/perf.js +91 -0
  121. package/lib/knowledge/plan.d.ts +22 -0
  122. package/lib/knowledge/plan.js +81 -0
  123. package/lib/knowledge/problems.d.ts +5 -0
  124. package/lib/knowledge/problems.js +8 -0
  125. package/lib/knowledge/query.d.ts +36 -0
  126. package/lib/knowledge/query.js +127 -0
  127. package/lib/knowledge/route-params.d.ts +6 -0
  128. package/lib/knowledge/route-params.js +42 -0
  129. package/lib/knowledge/screens.d.ts +30 -0
  130. package/lib/knowledge/screens.js +125 -0
  131. package/lib/knowledge/tree.d.ts +15 -0
  132. package/lib/knowledge/tree.js +63 -0
  133. package/lib/knowledge/types.d.ts +62 -0
  134. package/lib/knowledge/types.js +6 -0
  135. package/lib/knowledge/why.d.ts +24 -0
  136. package/lib/knowledge/why.js +153 -0
  137. package/lib/mcp/server.d.ts +4 -0
  138. package/lib/mcp/server.js +111 -0
  139. package/lib/registry.d.ts +78 -0
  140. package/lib/registry.js +113 -0
  141. package/lib/runtime.d.ts +12 -0
  142. package/lib/runtime.js +56 -0
  143. package/lib/targets.d.ts +17 -0
  144. package/lib/targets.js +43 -0
  145. package/package.json +47 -0
  146. package/src/__tests__/args.test.ts +82 -0
  147. package/src/__tests__/check.test.ts +71 -0
  148. package/src/__tests__/init.test.ts +52 -0
  149. package/src/__tests__/record.test.ts +49 -0
  150. package/src/__tests__/snapshot.test.ts +51 -0
  151. package/src/__tests__/targets.test.ts +132 -0
  152. package/src/args.ts +111 -0
  153. package/src/cases/__tests__/format.test.ts +76 -0
  154. package/src/cases/__tests__/runner.test.ts +230 -0
  155. package/src/cases/fixtures.ts +104 -0
  156. package/src/cases/format.ts +183 -0
  157. package/src/cases/runner.ts +575 -0
  158. package/src/cases/templates.ts +159 -0
  159. package/src/cli.ts +176 -0
  160. package/src/commands/cases.ts +170 -0
  161. package/src/commands/check.ts +83 -0
  162. package/src/commands/crawl/return.ts +130 -0
  163. package/src/commands/crawl/state.ts +70 -0
  164. package/src/commands/crawl.ts +262 -0
  165. package/src/commands/device.ts +119 -0
  166. package/src/commands/doctor.ts +237 -0
  167. package/src/commands/drive.ts +413 -0
  168. package/src/commands/flake.ts +48 -0
  169. package/src/commands/headless.ts +131 -0
  170. package/src/commands/init.ts +98 -0
  171. package/src/commands/map.ts +298 -0
  172. package/src/commands/os.ts +195 -0
  173. package/src/commands/overlay.ts +20 -0
  174. package/src/commands/record.ts +46 -0
  175. package/src/commands/runs.ts +475 -0
  176. package/src/commands/smoke.ts +86 -0
  177. package/src/commands/snapshot.ts +87 -0
  178. package/src/config.ts +103 -0
  179. package/src/device/devices.ts +44 -0
  180. package/src/device/driver.ts +197 -0
  181. package/src/device/platform.ts +74 -0
  182. package/src/files.ts +35 -0
  183. package/src/flow/artifacts.ts +61 -0
  184. package/src/flow/runner.ts +182 -0
  185. package/src/flow/types.ts +55 -0
  186. package/src/index.ts +18 -0
  187. package/src/knowledge/__tests__/edges.test.ts +34 -0
  188. package/src/knowledge/__tests__/events.test.ts +45 -0
  189. package/src/knowledge/__tests__/extract.test.ts +88 -0
  190. package/src/knowledge/__tests__/frontier.test.ts +77 -0
  191. package/src/knowledge/__tests__/funnels.test.ts +58 -0
  192. package/src/knowledge/__tests__/lint.test.ts +66 -0
  193. package/src/knowledge/__tests__/perf.test.ts +41 -0
  194. package/src/knowledge/__tests__/plan.test.ts +52 -0
  195. package/src/knowledge/__tests__/query.test.ts +74 -0
  196. package/src/knowledge/__tests__/screens.test.ts +37 -0
  197. package/src/knowledge/__tests__/why.test.ts +40 -0
  198. package/src/knowledge/edges.ts +91 -0
  199. package/src/knowledge/errors.ts +73 -0
  200. package/src/knowledge/events.ts +60 -0
  201. package/src/knowledge/extract.ts +379 -0
  202. package/src/knowledge/frontier.ts +82 -0
  203. package/src/knowledge/funnels.ts +126 -0
  204. package/src/knowledge/lint.ts +99 -0
  205. package/src/knowledge/perf.ts +114 -0
  206. package/src/knowledge/plan.ts +95 -0
  207. package/src/knowledge/problems.ts +12 -0
  208. package/src/knowledge/query.ts +146 -0
  209. package/src/knowledge/route-params.ts +39 -0
  210. package/src/knowledge/screens.ts +139 -0
  211. package/src/knowledge/tree.ts +66 -0
  212. package/src/knowledge/types.ts +60 -0
  213. package/src/knowledge/why.ts +189 -0
  214. package/src/mcp/server.ts +109 -0
  215. package/src/registry.ts +191 -0
  216. package/src/runtime.ts +54 -0
  217. package/src/targets.ts +44 -0
@@ -0,0 +1,379 @@
1
+ /**
2
+ * The screen graph of a React Navigation app, read from its source. Routes are
3
+ * declared on the navigator and implemented elsewhere, so a component is
4
+ * resolved through the type checker, not by file name. Most navigations live in
5
+ * shared components rather than screen files, so facts are recorded per file
6
+ * and attributed to screens later through the import graph.
7
+ */
8
+ import path from 'node:path';
9
+ import { Node, Project, SyntaxKind, type JsxAttribute, type SourceFile } from 'ts-morph';
10
+
11
+ import { packageName } from '../config.js';
12
+
13
+ import type { FileFacts, NavigationEdge, NavigationTrigger, ScreenGraph, ScreenNode, Selector, SourcePath } from './types.js';
14
+
15
+ // Only on a receiver that looks like the navigation object: `push` and `replace` are also Array and String methods.
16
+ const NAVIGATION_METHODS = new Set(['navigate', 'push', 'replace', 'reset']);
17
+
18
+ const isNavigationReceiver = (text: string): boolean => /(^|\.)nav(igation)?$/i.test(text.trim());
19
+
20
+ type ExtractOptions = {
21
+ root: string;
22
+ navigatorGlobs?: string[];
23
+ /** First-party source, relative to the root. */
24
+ sourceDir?: string;
25
+ };
26
+
27
+ export const extractScreenGraph = ({ root, navigatorGlobs = ['app/navigation/**/*.tsx'], sourceDir = 'app' }: ExtractOptions): ScreenGraph => {
28
+ const warnings: string[] = [];
29
+ const project = new Project({
30
+ tsConfigFilePath: path.join(root, 'tsconfig.json'),
31
+ skipAddingFilesFromTsConfig: false,
32
+ });
33
+
34
+ const rel = (file: SourceFile | string): SourcePath => path.relative(root, typeof file === 'string' ? file : file.getFilePath());
35
+
36
+ const sourceRoot = path.join(root, sourceDir);
37
+ const firstParty = project
38
+ .getSourceFiles()
39
+ .filter((f) => !f.getFilePath().includes('/node_modules/'))
40
+ .filter((f) => f.getFilePath().startsWith(sourceRoot));
41
+
42
+ const navigators = project.getSourceFiles(navigatorGlobs.map((g) => path.join(root, g))).filter((f) => !f.getFilePath().includes('/node_modules/'));
43
+
44
+ if (navigators.length === 0) {
45
+ warnings.push(`No navigator files matched ${navigatorGlobs.join(', ')}`);
46
+ }
47
+
48
+ const screens: Record<string, ScreenNode> = {};
49
+ for (const navigator of navigators) {
50
+ for (const { route, component } of screenDeclarations(navigator)) {
51
+ if (!component) {
52
+ warnings.push(`${rel(navigator)}: route "${route}" has no resolvable component`);
53
+ continue;
54
+ }
55
+
56
+ if (screens[route]) {
57
+ warnings.push(`Route "${route}" declared more than once; keeping the first`);
58
+ continue;
59
+ }
60
+
61
+ const file = resolveComponentFile(navigator, component);
62
+ if (!file) {
63
+ warnings.push(`${rel(navigator)}: "${route}" -> <${component}> unresolved to a file`);
64
+ }
65
+
66
+ screens[route] = {
67
+ route,
68
+ component,
69
+ file: file ? rel(file) : null,
70
+ declaredIn: rel(navigator),
71
+ };
72
+ }
73
+ }
74
+
75
+ const files: Record<SourcePath, FileFacts> = {};
76
+ for (const source of firstParty) {
77
+ files[rel(source)] = {
78
+ selectors: collectSelectors(source),
79
+ navigates: collectNavigationEdges(source),
80
+ ...collectImports(source, root),
81
+ };
82
+ }
83
+
84
+ return {
85
+ app: packageName(root),
86
+ root,
87
+ extractedAt: new Date().toISOString(),
88
+ screens,
89
+ files,
90
+ warnings,
91
+ };
92
+ };
93
+
94
+ type ScreenElement = { attr: (name: string) => JsxAttribute | undefined };
95
+
96
+ export type ScreenDeclaration = { route: string; component: string | null };
97
+
98
+ /** Routes in either of React Navigation's shapes: `<Stack.Screen name component />`, or `screens: { 'A.B': { screen: AB } }`. */
99
+ export const screenDeclarations = (source: SourceFile): ScreenDeclaration[] => {
100
+ const out: ScreenDeclaration[] = [];
101
+ for (const element of findScreenElements(source)) {
102
+ const route = stringAttribute(element.attr('name'));
103
+ if (route) out.push({ route, component: identifierAttribute(element.attr('component')) });
104
+ }
105
+ for (const prop of source.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
106
+ const value = prop.getInitializer();
107
+ if (!value || !Node.isObjectLiteralExpression(value)) continue;
108
+ const screen = value.getProperty('screen');
109
+ if (!screen || !Node.isPropertyAssignment(screen)) continue;
110
+ const nameNode = prop.getNameNode();
111
+ const route = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : Node.isIdentifier(nameNode) ? nameNode.getText() : null;
112
+ if (!route) continue;
113
+ // `params: { screen, params }` is React Navigation's nested-navigate payload, never a route.
114
+ if (route === 'params' || route === 'initialParams') continue;
115
+ const target = screen.getInitializer();
116
+ if (!target) continue;
117
+ if (Node.isCallExpression(target)) continue; // a nested navigator, not a screen
118
+ // `params: { screen: 'X' }` is a navigate() payload naming a route, not a declaration.
119
+ if (Node.isStringLiteral(target) || Node.isNoSubstitutionTemplateLiteral(target)) continue;
120
+ out.push({ route, component: Node.isIdentifier(target) ? target.getText() : null });
121
+ }
122
+ return out;
123
+ };
124
+
125
+ /** `<Stack.Screen …>` / `<Tab.Screen …>`, self-closing or not. */
126
+ const findScreenElements = (source: SourceFile): ScreenElement[] => {
127
+ const elements: ScreenElement[] = [];
128
+
129
+ const consider = (tagName: string, attributes: JsxAttribute[]) => {
130
+ if (!tagName.endsWith('.Screen') && tagName !== 'Screen') return;
131
+ elements.push({ attr: (name) => attributes.find((a) => a.getNameNode().getText() === name) });
132
+ };
133
+
134
+ for (const node of source.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) {
135
+ consider(node.getTagNameNode().getText(), node.getAttributes().filter(Node.isJsxAttribute));
136
+ }
137
+ for (const node of source.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) {
138
+ consider(node.getTagNameNode().getText(), node.getAttributes().filter(Node.isJsxAttribute));
139
+ }
140
+
141
+ return elements;
142
+ };
143
+
144
+ const stringAttribute = (attr: JsxAttribute | undefined): string | null => {
145
+ const initializer = attr?.getInitializer();
146
+ if (!initializer) return null;
147
+ if (Node.isStringLiteral(initializer)) return initializer.getLiteralValue();
148
+ if (Node.isJsxExpression(initializer)) {
149
+ const expression = initializer.getExpression();
150
+ if (expression && Node.isStringLiteral(expression)) return expression.getLiteralValue();
151
+ }
152
+ return null;
153
+ };
154
+
155
+ const identifierAttribute = (attr: JsxAttribute | undefined): string | null => {
156
+ const initializer = attr?.getInitializer();
157
+ if (!initializer || !Node.isJsxExpression(initializer)) return null;
158
+ const expression = initializer.getExpression();
159
+ return expression && Node.isIdentifier(expression) ? expression.getText() : null;
160
+ };
161
+
162
+ const resolveComponentFile = (navigator: SourceFile, componentName: string): SourceFile | null => {
163
+ const identifier = navigator.getDescendantsOfKind(SyntaxKind.Identifier).find((id) => id.getText() === componentName);
164
+ if (!identifier) return null;
165
+
166
+ for (const definition of identifier.getDefinitionNodes()) {
167
+ const file = definition.getSourceFile();
168
+ if (file.getFilePath().includes('/node_modules/')) continue;
169
+ // A barrel re-export resolves to the export declaration itself; keep walking.
170
+ if (Node.isExportSpecifier(definition) || Node.isExportDeclaration(definition)) continue;
171
+ return file;
172
+ }
173
+ return null;
174
+ };
175
+
176
+ const collectSelectors = (source: SourceFile): Selector[] => {
177
+ const selectors: Selector[] = [];
178
+ for (const attr of source.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
179
+ if (attr.getNameNode().getText() !== 'testID') continue;
180
+ const id = stringAttribute(attr);
181
+ if (!id) continue; // dynamic testID: the agent discovers it at runtime instead
182
+ selectors.push({ id, line: attr.getStartLineNumber() });
183
+ }
184
+ return selectors;
185
+ };
186
+
187
+ const collectNavigationEdges = (source: SourceFile): NavigationEdge[] => {
188
+ const handlerTriggers = mapHandlersToTriggers(source);
189
+ const edges: NavigationEdge[] = [];
190
+
191
+ for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
192
+ const callee = call.getExpression();
193
+ if (!Node.isPropertyAccessExpression(callee)) continue;
194
+ if (!NAVIGATION_METHODS.has(callee.getNameNode().getText())) continue;
195
+ if (!isNavigationReceiver(callee.getExpression().getText())) continue;
196
+
197
+ const target = call.getArguments()[0];
198
+ if (!target) continue;
199
+
200
+ const trigger = triggerFor(call, handlerTriggers);
201
+ edges.push({
202
+ ...(Node.isStringLiteral(target)
203
+ ? { to: target.getLiteralValue(), confidence: 'static' as const }
204
+ : {
205
+ to: target.getText().replace(/\s+/g, ' ').slice(0, 120),
206
+ confidence: 'dynamic' as const,
207
+ }),
208
+ line: call.getStartLineNumber(),
209
+ via: trigger.via,
210
+ ...(trigger.linkage ? { linkage: trigger.linkage } : {}),
211
+ });
212
+ }
213
+ return edges;
214
+ };
215
+
216
+ const testIdOfElementOwning = (attribute: Node): NavigationTrigger | null => {
217
+ const element = attribute.getFirstAncestor((a) => Node.isJsxSelfClosingElement(a) || Node.isJsxOpeningElement(a));
218
+ if (!element) return null;
219
+
220
+ const attributes = (Node.isJsxSelfClosingElement(element) || Node.isJsxOpeningElement(element) ? element.getAttributes() : []).filter(Node.isJsxAttribute);
221
+
222
+ const testId = attributes.find((a) => a.getNameNode().getText() === 'testID');
223
+ if (!testId) return null;
224
+
225
+ const literal = stringAttribute(testId);
226
+ if (literal) return { kind: 'testId', testId: literal };
227
+
228
+ // Assembled at runtime (a list row keyed by item, a prop passed down): the device resolves it.
229
+ const initializer = testId.getInitializer();
230
+ return {
231
+ kind: 'dynamicTestId',
232
+ expression: (initializer?.getText() ?? '').replace(/\s+/g, ' ').slice(0, 80),
233
+ };
234
+ };
235
+
236
+ // Separate from the one above: `getFirstAncestorByKind` does not match the node itself, so given an attribute it would look past it.
237
+ const owningElementTestId = (node: Node): NavigationTrigger | null => {
238
+ const attribute = node.getFirstAncestorByKind(SyntaxKind.JsxAttribute);
239
+ return attribute ? testIdOfElementOwning(attribute) : null;
240
+ };
241
+
242
+ /** `onPress={goToProfile}` on an element with `testID="profile-button"`: the handler's name to that testID, once per file. */
243
+ const mapHandlersToTriggers = (source: SourceFile): Map<string, NavigationTrigger> => {
244
+ const handlers = new Map<string, NavigationTrigger>();
245
+
246
+ for (const attribute of source.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
247
+ const initializer = attribute.getInitializer();
248
+ if (!initializer || !Node.isJsxExpression(initializer)) continue;
249
+
250
+ const expression = initializer.getExpression();
251
+ if (!expression || !Node.isIdentifier(expression)) continue;
252
+
253
+ const trigger = testIdOfElementOwning(attribute);
254
+ if (trigger) handlers.set(expression.getText(), trigger);
255
+ }
256
+
257
+ return handlers;
258
+ };
259
+
260
+ const triggerFor = (call: Node, handlers: Map<string, NavigationTrigger>): { via: NavigationTrigger; linkage?: 'element' | 'handler' } => {
261
+ const direct = owningElementTestId(call);
262
+ if (direct) return { via: direct, linkage: 'element' };
263
+
264
+ const declaration = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
265
+ const viaHandler = declaration && handlers.get(declaration.getName());
266
+ if (viaHandler) return { via: viaHandler, linkage: 'handler' };
267
+
268
+ return { via: { kind: 'unknown' } };
269
+ };
270
+
271
+ const localExports = new WeakMap<SourceFile, Set<string>>();
272
+
273
+ // The names a file defines itself, read from its syntax: no type checker, so a
274
+ // whole app resolves in the time it takes to parse it.
275
+ const exportedHere = (source: SourceFile): Set<string> => {
276
+ const known = localExports.get(source);
277
+ if (known) return known;
278
+ const names = new Set<string>();
279
+ for (const statement of source.getStatements()) {
280
+ if (Node.isExportAssignment(statement)) names.add('default');
281
+ if (Node.isExportDeclaration(statement)) {
282
+ if (!statement.getModuleSpecifier()) for (const spec of statement.getNamedExports()) names.add(spec.getAliasNode()?.getText() ?? spec.getName());
283
+ continue;
284
+ }
285
+ if (!Node.isExportable(statement) || !statement.isExported()) continue;
286
+ if (statement.isDefaultExport()) names.add('default');
287
+ if (Node.isVariableStatement(statement)) for (const d of statement.getDeclarations()) names.add(d.getName());
288
+ else if (Node.hasName(statement)) names.add(statement.getName());
289
+ }
290
+ localExports.set(source, names);
291
+ return names;
292
+ };
293
+
294
+ type ReExport = { target: SourceFile; namespace?: string; names?: Map<string, string> };
295
+ const reExports = new WeakMap<SourceFile, ReExport[]>();
296
+
297
+ const reExportsOf = (source: SourceFile): ReExport[] => {
298
+ const known = reExports.get(source);
299
+ if (known) return known;
300
+ const list: ReExport[] = [];
301
+ for (const declaration of source.getExportDeclarations()) {
302
+ const target = declaration.getModuleSpecifierSourceFile();
303
+ if (!target || target.getFilePath().includes('/node_modules/')) continue;
304
+ const namespace = declaration.getNamespaceExport()?.getName();
305
+ const named = declaration.getNamedExports();
306
+ list.push({
307
+ target,
308
+ namespace,
309
+ names: named.length ? new Map(named.map((spec) => [spec.getAliasNode()?.getText() ?? spec.getName(), spec.getName()])) : undefined,
310
+ });
311
+ }
312
+ reExports.set(source, list);
313
+ return list;
314
+ };
315
+
316
+ const defined = new WeakMap<SourceFile, Map<string, SourceFile | undefined>>();
317
+
318
+ /** The file that defines `name`, following re-exports from `source`; undefined when it cannot be told. */
319
+ const definingFile = (source: SourceFile, name: string, seen = new Set<SourceFile>()): SourceFile | undefined => {
320
+ const memo = defined.get(source) ?? new Map<string, SourceFile | undefined>();
321
+ defined.set(source, memo);
322
+ if (memo.has(name)) return memo.get(name);
323
+ if (seen.has(source)) return undefined;
324
+ seen.add(source);
325
+ const find = (): SourceFile | undefined => {
326
+ if (exportedHere(source).has(name)) return source;
327
+ for (const { target, namespace, names } of reExportsOf(source)) {
328
+ if (namespace !== undefined) {
329
+ if (namespace === name) return target;
330
+ continue;
331
+ }
332
+ if (!names) {
333
+ const found = definingFile(target, name, seen);
334
+ if (found) return found;
335
+ continue;
336
+ }
337
+ const original = names.get(name);
338
+ if (original !== undefined) return definingFile(target, original, seen) ?? target;
339
+ }
340
+ return undefined;
341
+ };
342
+ const found = find();
343
+ memo.set(name, found);
344
+ return found;
345
+ };
346
+
347
+ /**
348
+ * Import edges. A named import through a barrel (`import { useThing } from
349
+ * '~hooks'`) points at the file that defines the name, not at the barrel:
350
+ * otherwise a change to one hook reaches every screen that imports any hook.
351
+ * The barrels passed through are kept apart, for when the barrel itself changes.
352
+ */
353
+ export const collectImports = (source: SourceFile, root: string): { imports: SourcePath[]; barrels: SourcePath[] } => {
354
+ const imports = new Set<SourcePath>();
355
+ const barrels = new Set<SourcePath>();
356
+ const own = (file: SourceFile | undefined): file is SourceFile => !!file && !file.getFilePath().includes('/node_modules/');
357
+ const relative = (file: SourceFile) => path.relative(root, file.getFilePath());
358
+
359
+ for (const declaration of source.getImportDeclarations()) {
360
+ const target = declaration.getModuleSpecifierSourceFile();
361
+ if (!own(target)) continue;
362
+ const named = declaration.getNamedImports();
363
+ if (named.length === 0 || declaration.getDefaultImport() || declaration.getNamespaceImport()) imports.add(relative(target));
364
+ for (const spec of named) {
365
+ const defined = definingFile(target, spec.getName());
366
+ if (!defined || defined === target) imports.add(relative(target));
367
+ else {
368
+ imports.add(relative(defined));
369
+ barrels.add(relative(target));
370
+ }
371
+ }
372
+ }
373
+ for (const declaration of source.getExportDeclarations()) {
374
+ const target = declaration.getModuleSpecifierSourceFile();
375
+ if (own(target)) imports.add(relative(target));
376
+ }
377
+
378
+ return { imports: [...imports], barrels: [...barrels].filter((b) => !imports.has(b)) };
379
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * What is left to press, kept between crawls as `frontier.jsonl`: per place
3
+ * the agent has stood, which controls were pressed and which are still owed,
4
+ * so the next run picks up where the last one's budget ran out. Stored the way
5
+ * edges.jsonl is: appended while a run lasts, merged on read, sorted at the end.
6
+ */
7
+ import { appendJsonl, readJsonl, today, writeJsonl } from '../files.js';
8
+
9
+ export type Visited = {
10
+ /** A route, or a layer open over one (`Settings.Main#account-button`). */
11
+ node: string;
12
+ tried: string[];
13
+ /** Controls seen here and not pressed yet. */
14
+ left: string[];
15
+ /** A place only ever seen from outside still owes a first visit. */
16
+ visited: boolean;
17
+ last: string;
18
+ };
19
+
20
+ export type FrontierUpdate = { tried?: string[]; seen?: string[]; visited?: boolean };
21
+
22
+ const merge = (into: Visited, row: Visited) => {
23
+ const tried = new Set([...into.tried, ...row.tried]);
24
+ into.tried = [...tried].sort();
25
+ into.left = [...new Set([...into.left, ...row.left])].filter((c) => !tried.has(c)).sort();
26
+ into.visited ||= row.visited;
27
+ if (row.last > into.last) into.last = row.last;
28
+ };
29
+
30
+ const add = (byNode: Map<string, Visited>, row: Visited) => {
31
+ let found = byNode.get(row.node);
32
+ if (!found) {
33
+ found = { node: row.node, tried: [], left: [], visited: false, last: '' };
34
+ byNode.set(row.node, found);
35
+ }
36
+ merge(found, row);
37
+ };
38
+
39
+ const merged = (rows: Visited[]): Map<string, Visited> => {
40
+ const byNode = new Map<string, Visited>();
41
+ for (const row of rows) add(byNode, row);
42
+ return byNode;
43
+ };
44
+
45
+ const save = (file: string, rows: Visited[]) =>
46
+ writeJsonl(
47
+ file,
48
+ [...rows].sort((a, b) => a.node.localeCompare(b.node)),
49
+ );
50
+
51
+ export const load = (file: string): Visited[] => [...merged(readJsonl<Visited>(file)).values()];
52
+
53
+ /** Places that still owe something, the never-visited first: what they offer is unknown, which is worth more than a known remainder. */
54
+ export const unfinished = (rows: Visited[]): Visited[] =>
55
+ rows.filter((r) => !r.visited || r.left.length > 0).sort((a, b) => Number(a.visited) - Number(b.visited) || b.left.length - a.left.length);
56
+
57
+ /** The frontier in a file, kept in memory while a crawl adds to it. */
58
+ export const open = (file: string) => {
59
+ const rows = merged(readJsonl<Visited>(file));
60
+ let appended = false;
61
+ return {
62
+ tried: (node: string): string[] => rows.get(node)?.tried ?? [],
63
+ record: (node: string, update: FrontierUpdate): void => {
64
+ const row: Visited = { node, tried: update.tried ?? [], left: update.seen ?? [], visited: update.visited === true, last: today() };
65
+ appendJsonl(file, row);
66
+ appended = true;
67
+ add(rows, row);
68
+ },
69
+ unfinished: (): string[] => unfinished([...rows.values()]).map((r) => r.node),
70
+ compact: () => {
71
+ if (appended) save(file, load(file));
72
+ },
73
+ };
74
+ };
75
+
76
+ export const prune = (file: string, routes: Set<string>): number => {
77
+ const before = load(file);
78
+ if (before.length === 0) return 0;
79
+ const after = before.filter((r) => routes.has(r.node.split('#')[0]!));
80
+ save(file, after);
81
+ return before.length - after.length;
82
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The analytics contract checked against what a run recorded: not conversion
3
+ * (a scripted run always converts) but that the funnel a dashboard reads still
4
+ * exists in the code, in order, with the properties it needs.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ import { readJson } from '../files.js';
10
+ import type { AppEvent, RunEvent } from './events.js';
11
+
12
+ /** A property is either a type the dashboard needs (`string`, `number`, `boolean`) or a value it filters on. */
13
+ export type FunnelStep = { event: string; props?: Record<string, unknown> };
14
+ export type Funnel = { name: string; steps: FunnelStep[] };
15
+
16
+ const funnelsDir = (lynkeusDir: string) => path.join(lynkeusDir, 'funnels');
17
+
18
+ export const loadFunnel = (lynkeusDir: string, name: string): Funnel => {
19
+ const file = path.join(funnelsDir(lynkeusDir), `${name}.json`);
20
+ if (!fs.existsSync(file)) throw new Error(`no funnel ${name}: write ${path.relative(process.cwd(), file)}`);
21
+ const funnel = readJson<Funnel>(file);
22
+ return { ...funnel, name: funnel.name ?? name };
23
+ };
24
+
25
+ export const listFunnels = (lynkeusDir: string): Funnel[] => {
26
+ const dir = funnelsDir(lynkeusDir);
27
+ if (!fs.existsSync(dir)) return [];
28
+ return fs
29
+ .readdirSync(dir)
30
+ .filter((f) => f.endsWith('.json'))
31
+ .map((f) => loadFunnel(lynkeusDir, f.slice(0, -5)));
32
+ };
33
+
34
+ const TYPES = new Set(['string', 'number', 'boolean']);
35
+ const typeOf = (v: unknown) => (Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v);
36
+
37
+ const propProblems = (want: Record<string, unknown> = {}, got: Record<string, unknown> = {}): string[] =>
38
+ Object.entries(want).flatMap(([key, expected]) => {
39
+ if (!(key in got)) return [`${key} is missing`];
40
+ const value = got[key];
41
+ if (typeof expected === 'string' && TYPES.has(expected))
42
+ return typeOf(value) === expected ? [] : [`${key} is ${typeOf(value)}, the funnel needs ${expected}`];
43
+ return JSON.stringify(value) === JSON.stringify(expected) ? [] : [`${key} is ${JSON.stringify(value)}, the funnel filters on ${JSON.stringify(expected)}`];
44
+ });
45
+
46
+ export type FunnelResult = { funnel: string; ok: boolean; steps: { event: string; ok: boolean; at?: number; problem?: string }[] };
47
+
48
+ export const checkFunnel = (funnel: Funnel, events: (AppEvent | RunEvent)[]): FunnelResult => {
49
+ let cursor = 0;
50
+ const steps = funnel.steps.map((step) => {
51
+ const index = events.findIndex((e, i) => i >= cursor && e.name === step.event);
52
+ if (index === -1) {
53
+ const earlier = events.findIndex((e) => e.name === step.event);
54
+ return { event: step.event, ok: false, problem: earlier === -1 ? 'never happened' : 'happened, but before the step it should follow' };
55
+ }
56
+ cursor = index + 1;
57
+ const at = 'step' in events[index]! ? (events[index] as RunEvent).step : index;
58
+ const problems = propProblems(step.props, events[index]!.props);
59
+ return problems.length ? { event: step.event, ok: false, at, problem: problems.join('; ') } : { event: step.event, ok: true, at };
60
+ });
61
+ return { funnel: funnel.name, ok: steps.every((s) => s.ok), steps };
62
+ };
63
+
64
+ export const describeFunnel = (result: FunnelResult): string =>
65
+ [
66
+ `${result.ok ? '✓' : '✗'} ${result.funnel}`,
67
+ ...result.steps.map(
68
+ (s, i) => ` ${s.ok ? '✓' : '✗'} ${i + 1} ${s.event}${s.at !== undefined ? ` (step ${s.at})` : ''}${s.problem ? ` — ${s.problem}` : ''}`,
69
+ ),
70
+ ].join('\n');
71
+
72
+ type Shape = Record<string, string>;
73
+ const shapeOf = (events: AppEvent[]): Map<string, Shape> => {
74
+ const out = new Map<string, Shape>();
75
+ for (const e of events) {
76
+ const shape = out.get(e.name) ?? {};
77
+ for (const [k, v] of Object.entries(e.props ?? {})) shape[k] = typeOf(v);
78
+ out.set(e.name, shape);
79
+ }
80
+ return out;
81
+ };
82
+
83
+ export type EventsDiff = { added: string[]; lost: string[]; changed: { event: string; changes: string[] }[] };
84
+
85
+ /** The same case on two builds: events that appeared, disappeared, or changed the shape of their properties. */
86
+ export const diffEvents = (before: AppEvent[], after: AppEvent[]): EventsDiff => {
87
+ const a = shapeOf(before);
88
+ const b = shapeOf(after);
89
+ const added = [...b.keys()].filter((n) => !a.has(n)).sort();
90
+ const lost = [...a.keys()].filter((n) => !b.has(n)).sort();
91
+ const changed = [...a.keys()]
92
+ .filter((n) => b.has(n))
93
+ .sort()
94
+ .flatMap((event) => {
95
+ const was = a.get(event)!;
96
+ const now = b.get(event)!;
97
+ const changes = [
98
+ ...Object.keys(now)
99
+ .filter((k) => !(k in was))
100
+ .map((k) => `+${k}`),
101
+ ...Object.keys(was)
102
+ .filter((k) => !(k in now))
103
+ .map((k) => `-${k}`),
104
+ ...Object.keys(was)
105
+ .filter((k) => k in now && was[k] !== now[k])
106
+ .map((k) => `${k} ${was[k]}→${now[k]}`),
107
+ ];
108
+ return changes.length ? [{ event, changes }] : [];
109
+ });
110
+ return { added, lost, changed };
111
+ };
112
+
113
+ export const describeEventsDiff = (diff: EventsDiff): string => {
114
+ const lines = [...diff.added.map((n) => `+ ${n}`), ...diff.lost.map((n) => `- ${n}`), ...diff.changed.map((c) => `~ ${c.event}: ${c.changes.join(', ')}`)];
115
+ return lines.length ? lines.join('\n') : '(same events, same shapes)';
116
+ };
117
+
118
+ /** Across runs: which funnel steps some run produced, and which none did. */
119
+ export const funnelCoverage = (funnels: Funnel[], runs: { run: string; events: AppEvent[] }[]) =>
120
+ funnels.map((funnel) => ({
121
+ funnel: funnel.name,
122
+ steps: funnel.steps.map((step) => ({
123
+ event: step.event,
124
+ runs: runs.filter((r) => r.events.some((e) => e.name === step.event)).map((r) => r.run),
125
+ })),
126
+ }));
@@ -0,0 +1,99 @@
1
+ /** What a person would flag in review, read off one screen: not accessibility certification, the usual suspects. */
2
+ import type { Screen } from 'lynkeus-protocol';
3
+
4
+ export type Finding = { rule: string; severity: 'warn' | 'info'; element: number; message: string };
5
+
6
+ const DEFAULT_I18N_KEY_PATTERNS = [/^[a-z][a-z0-9]*(\.[a-z0-9]+){2,}$/i, /^[a-z0-9]+(_[a-z0-9]+){2,}$/i];
7
+
8
+ const looksLikeI18nKey = (t: string, patterns: RegExp[]): boolean => {
9
+ const s = t.trim();
10
+ if (patterns.length === 0) return false;
11
+ if (s.length < 4 || /\s/.test(s)) return false;
12
+ if (/^https?:|^[@#$]|^\+?\d[\d\s.,-]*$/.test(s)) return false;
13
+ return patterns.some((p) => p.test(s));
14
+ };
15
+
16
+ // Points: what both platforms' guidelines ask for.
17
+ const MIN_TOUCH = 44;
18
+ const label = (e: Screen['elements'][number]) => e.testId ?? e.text ?? e.accessibilityLabel;
19
+
20
+ type LintOptions = {
21
+ /** What an untranslated key looks like (default `a.b.c` and `snake_case_key`); `[]` turns the rule off. */
22
+ i18nKeyPatterns?: RegExp[];
23
+ minTouchTarget?: number;
24
+ };
25
+
26
+ export const lintScreen = (screen: Screen, options: LintOptions = {}): Finding[] => {
27
+ const minTouch = options.minTouchTarget ?? MIN_TOUCH;
28
+ const out: Finding[] = [];
29
+ const els = (screen.elements ?? []).filter((e) => !e.hidden);
30
+ const win = screen.window ?? { w: 0, h: 0 };
31
+ for (const e of els) {
32
+ const named = e.text ?? e.accessibilityLabel;
33
+ if ((e.kind === 'button' || e.kind === 'switch') && named === undefined && e.testId === undefined) {
34
+ out.push({ rule: 'unlabeled-control', severity: 'warn', element: e.i, message: `${e.kind} with no text, label or testId` });
35
+ }
36
+ if ((e.kind === 'button' || e.kind === 'switch') && e.enabled !== false && e.frame) {
37
+ const { w, h } = e.frame;
38
+ // A control with text is usually measured at its label, not its touchable area (a tab bar item): only info.
39
+ if (w > 0 && h > 0 && (w < minTouch || h < minTouch)) {
40
+ const iconOnly = e.text === undefined && e.accessibilityLabel === undefined;
41
+ out.push({
42
+ rule: 'small-target',
43
+ severity: iconOnly ? 'warn' : 'info',
44
+ element: e.i,
45
+ message: `${Math.round(w)}×${Math.round(h)}pt target (< ${minTouch}) ${label(e) ?? ''}`.trim(),
46
+ });
47
+ }
48
+ }
49
+ if (e.kind === 'text' && e.text && looksLikeI18nKey(e.text, options.i18nKeyPatterns ?? DEFAULT_I18N_KEY_PATTERNS)) {
50
+ out.push({ rule: 'untranslated', severity: 'warn', element: e.i, message: `"${e.text}" looks like an i18n key, not copy` });
51
+ }
52
+ if ((e.kind === 'button' || e.kind === 'input' || e.kind === 'switch') && e.covered && e.enabled !== false) {
53
+ out.push({
54
+ rule: 'covered-control',
55
+ severity: 'info',
56
+ element: e.i,
57
+ message: `${e.kind} ${label(e) ?? ''} is covered; a tap would not reach it`.replace(' ', ' ').trim(),
58
+ });
59
+ }
60
+ if (e.frame && win.w > 0 && label(e) !== undefined) {
61
+ const { x, y, w, h } = e.frame;
62
+ if (x + w > win.w + 1 || y + h > win.h + 1 || x < -1 || y < -1) {
63
+ out.push({
64
+ rule: 'offscreen',
65
+ severity: 'info',
66
+ element: e.i,
67
+ message: `${label(e)} extends past the ${win.w}×${win.h} window (x${Math.round(x)} y${Math.round(y)} ${Math.round(w)}×${Math.round(h)})`,
68
+ });
69
+ }
70
+ }
71
+ if (e.text && e.frame && (e.frame.w === 0 || e.frame.h === 0)) {
72
+ out.push({
73
+ rule: 'zero-size',
74
+ severity: 'info',
75
+ element: e.i,
76
+ message: `"${e.text.slice(0, 30)}" has ${Math.round(e.frame.w)}×${Math.round(e.frame.h)} size`,
77
+ });
78
+ }
79
+ }
80
+ const byTestId = new Map<string, number[]>();
81
+ for (const e of els) {
82
+ if (!e.testId) continue;
83
+ const list = byTestId.get(e.testId) ?? [];
84
+ list.push(e.i);
85
+ byTestId.set(e.testId, list);
86
+ }
87
+ for (const [id, at] of byTestId)
88
+ if (at.length > 1)
89
+ out.push({ rule: 'duplicate-testid', severity: 'info', element: at[0]!, message: `testId "${id}" is on ${at.length} elements (${at.join(', ')})` });
90
+ return out;
91
+ };
92
+
93
+ export const describeFindings = (findings: Finding[], route?: string): string => {
94
+ if (findings.length === 0) return `${route ?? 'screen'}: clean`;
95
+ const order = { warn: 0, info: 1 } as const;
96
+ const sorted = [...findings].sort((a, b) => order[a.severity] - order[b.severity]);
97
+ const head = `${route ?? 'screen'}: ${findings.filter((f) => f.severity === 'warn').length} warn, ${findings.filter((f) => f.severity === 'info').length} info`;
98
+ return [head, ...sorted.map((f) => ` ${f.severity === 'warn' ? '⚠' : '·'} [${f.rule}] #${f.element} ${f.message}`)].join('\n');
99
+ };