@craft-ts/dev-tools 0.7.0-beta.13

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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -0
  3. package/generators.json +26 -0
  4. package/package.json +119 -0
  5. package/src/bin/craft-architecture-check.d.ts +2 -0
  6. package/src/bin/craft-architecture-check.js +51 -0
  7. package/src/bin/craft-architecture-check.js.map +1 -0
  8. package/src/bin/craft-brand.d.ts +2 -0
  9. package/src/bin/craft-brand.js +9 -0
  10. package/src/bin/craft-brand.js.map +1 -0
  11. package/src/bin/craft-graph.d.ts +2 -0
  12. package/src/bin/craft-graph.js +72 -0
  13. package/src/bin/craft-graph.js.map +1 -0
  14. package/src/bin/craft-migrate-architecture.d.ts +2 -0
  15. package/src/bin/craft-migrate-architecture.js +64 -0
  16. package/src/bin/craft-migrate-architecture.js.map +1 -0
  17. package/src/bin/craft-migrate-components.d.ts +2 -0
  18. package/src/bin/craft-migrate-components.js +67 -0
  19. package/src/bin/craft-migrate-components.js.map +1 -0
  20. package/src/bin/craft-migrate-primitives.d.ts +2 -0
  21. package/src/bin/craft-migrate-primitives.js +71 -0
  22. package/src/bin/craft-migrate-primitives.js.map +1 -0
  23. package/src/bin/craft-migrate-routes.d.ts +2 -0
  24. package/src/bin/craft-migrate-routes.js +77 -0
  25. package/src/bin/craft-migrate-routes.js.map +1 -0
  26. package/src/bin/craft-migrate-services.d.ts +2 -0
  27. package/src/bin/craft-migrate-services.js +75 -0
  28. package/src/bin/craft-migrate-services.js.map +1 -0
  29. package/src/bin/craft-migrate-template.d.ts +2 -0
  30. package/src/bin/craft-migrate-template.js +68 -0
  31. package/src/bin/craft-migrate-template.js.map +1 -0
  32. package/src/bin/craft-migrate.d.ts +2 -0
  33. package/src/bin/craft-migrate.js +91 -0
  34. package/src/bin/craft-migrate.js.map +1 -0
  35. package/src/bin/craft.d.ts +2 -0
  36. package/src/bin/craft.js +318 -0
  37. package/src/bin/craft.js.map +1 -0
  38. package/src/eslint-rules/anchor-has-href.cjs +35 -0
  39. package/src/eslint-rules/app-start-registry-match.cjs +355 -0
  40. package/src/eslint-rules/button-has-type.cjs +50 -0
  41. package/src/eslint-rules/control-has-accessible-name.cjs +56 -0
  42. package/src/eslint-rules/craft-component-name-match.cjs +8 -0
  43. package/src/eslint-rules/craft-computed-name-match.cjs +63 -0
  44. package/src/eslint-rules/craft-css-token-registry.cjs +17 -0
  45. package/src/eslint-rules/craft-css-var-naming.cjs +18 -0
  46. package/src/eslint-rules/craft-css-vars-contract.cjs +34 -0
  47. package/src/eslint-rules/craft-directive-name-match.cjs +8 -0
  48. package/src/eslint-rules/craft-method-name-match.cjs +8 -0
  49. package/src/eslint-rules/craft-name-match-utils.cjs +179 -0
  50. package/src/eslint-rules/craft-primitive-context.cjs +46 -0
  51. package/src/eslint-rules/craft-signal-source-name-match.cjs +8 -0
  52. package/src/eslint-rules/craft-source-name-match.cjs +8 -0
  53. package/src/eslint-rules/craft-styles-scope-safe.cjs +25 -0
  54. package/src/eslint-rules/css-rule-utils.cjs +109 -0
  55. package/src/eslint-rules/global-exception-registry-match.cjs +562 -0
  56. package/src/eslint-rules/heading-has-content.cjs +31 -0
  57. package/src/eslint-rules/html-helpers.cjs +115 -0
  58. package/src/eslint-rules/hyperscript-walk.cjs +249 -0
  59. package/src/eslint-rules/iframe-has-title.cjs +26 -0
  60. package/src/eslint-rules/img-has-alt.cjs +28 -0
  61. package/src/eslint-rules/index.cjs +209 -0
  62. package/src/eslint-rules/index.d.cts +5 -0
  63. package/src/eslint-rules/label-has-associated-control.cjs +67 -0
  64. package/src/eslint-rules/no-async-await.cjs +46 -0
  65. package/src/eslint-rules/no-craft-computed-side-effects.cjs +301 -0
  66. package/src/eslint-rules/no-craft-use-in-template.cjs +89 -0
  67. package/src/eslint-rules/no-craft-use.cjs +42 -0
  68. package/src/eslint-rules/no-direct-temporal-globals.cjs +73 -0
  69. package/src/eslint-rules/no-effect-in-params.cjs +265 -0
  70. package/src/eslint-rules/no-effect-outside-loaders.cjs +3 -0
  71. package/src/eslint-rules/no-ephemeral-template-form-state.cjs +131 -0
  72. package/src/eslint-rules/no-hardcoded-design-values.cjs +15 -0
  73. package/src/eslint-rules/no-heading-level-skip.cjs +101 -0
  74. package/src/eslint-rules/no-imperative-craft-resource-trigger.cjs +291 -0
  75. package/src/eslint-rules/no-important-in-component-styles.cjs +12 -0
  76. package/src/eslint-rules/no-injection-token.cjs +173 -0
  77. package/src/eslint-rules/no-invalid-insertion-pipe.cjs +162 -0
  78. package/src/eslint-rules/no-manual-route-provider-list.cjs +85 -0
  79. package/src/eslint-rules/no-noninteractive-element-interactions.cjs +66 -0
  80. package/src/eslint-rules/no-positive-tabindex.cjs +44 -0
  81. package/src/eslint-rules/no-redundant-primitive-insertion.cjs +361 -0
  82. package/src/eslint-rules/no-render-writes.cjs +106 -0
  83. package/src/eslint-rules/no-throw.cjs +132 -0
  84. package/src/eslint-rules/no-transition-actions.cjs +133 -0
  85. package/src/eslint-rules/no-type-assertions-in-template.cjs +211 -0
  86. package/src/eslint-rules/no-widened-route-provider-context.cjs +39 -0
  87. package/src/eslint-rules/prefer-browser-boundaries.cjs +92 -0
  88. package/src/eslint-rules/prefer-craft-http-transport.cjs +103 -0
  89. package/src/eslint-rules/prefer-craft-template-blocks.cjs +492 -0
  90. package/src/eslint-rules/prefer-direct-yieldable-callback.cjs +105 -0
  91. package/src/eslint-rules/prefer-named-html-helpers.cjs +33 -0
  92. package/src/eslint-rules/prefer-query-method-over-state-trigger.cjs +282 -0
  93. package/src/eslint-rules/prefer-relative-heading.cjs +43 -0
  94. package/src/eslint-rules/provide-host-name-match-component.cjs +455 -0
  95. package/src/eslint-rules/recommended-config.cjs +77 -0
  96. package/src/eslint-rules/require-assert-exhaustive-route-exceptions.cjs +259 -0
  97. package/src/eslint-rules/require-cascade-route-di-check.cjs +223 -0
  98. package/src/eslint-rules/require-child-route-mount-check.cjs +224 -0
  99. package/src/eslint-rules/require-component-monitoring.cjs +212 -0
  100. package/src/eslint-rules/require-craft-exception-handler.cjs +142 -0
  101. package/src/eslint-rules/require-craft-method-for-yieldable-callback.cjs +236 -0
  102. package/src/eslint-rules/require-craft-resource-trigger-yield.cjs +209 -0
  103. package/src/eslint-rules/require-effect-adapters.cjs +86 -0
  104. package/src/eslint-rules/require-exception-component-di-check.cjs +314 -0
  105. package/src/eslint-rules/require-focus-visible.cjs +36 -0
  106. package/src/eslint-rules/require-interactive-local-name.cjs +105 -0
  107. package/src/eslint-rules/require-lazy-load-with-retry.cjs +148 -0
  108. package/src/eslint-rules/require-outlet-heading-section.cjs +70 -0
  109. package/src/eslint-rules/require-pending-component-di-check.cjs +490 -0
  110. package/src/eslint-rules/require-primitive-context.cjs +156 -0
  111. package/src/eslint-rules/require-primitive-derived-property.cjs +598 -0
  112. package/src/eslint-rules/require-primitive-generator-unwrap.cjs +274 -0
  113. package/src/eslint-rules/require-reactive-template-bindings.cjs +305 -0
  114. package/src/eslint-rules/require-reduced-motion.cjs +38 -0
  115. package/src/eslint-rules/require-route-heading-outline.cjs +166 -0
  116. package/src/eslint-rules/require-yieldable-insertion-write.cjs +98 -0
  117. package/src/eslint-rules/require-yieldable-reactive-read.cjs +102 -0
  118. package/src/eslint-rules/require-yieldable-template-method.cjs +424 -0
  119. package/src/eslint-rules/role-has-required-aria.cjs +38 -0
  120. package/src/eslint-rules/server-function-client-match.cjs +283 -0
  121. package/src/eslint-rules/target-blank-noopener.cjs +31 -0
  122. package/src/eslint-rules/template-element-name-unique.cjs +124 -0
  123. package/src/eslint-rules/valid-aria.cjs +38 -0
  124. package/src/generators/route/compat.d.ts +3 -0
  125. package/src/generators/route/compat.js +6 -0
  126. package/src/generators/route/compat.js.map +1 -0
  127. package/src/generators/route/generator.d.ts +31 -0
  128. package/src/generators/route/generator.js +402 -0
  129. package/src/generators/route/generator.js.map +1 -0
  130. package/src/generators/route/schema.json +50 -0
  131. package/src/generators/route/split-schema.json +19 -0
  132. package/src/index.d.ts +21 -0
  133. package/src/index.js +23 -0
  134. package/src/index.js.map +1 -0
  135. package/src/scripts/angular-brand-codemod.d.ts +139 -0
  136. package/src/scripts/angular-brand-codemod.js +2318 -0
  137. package/src/scripts/angular-brand-codemod.js.map +1 -0
  138. package/src/scripts/angular-brand-codemod.ts +3985 -0
  139. package/src/scripts/architecture/migrate-architecture.d.ts +14 -0
  140. package/src/scripts/architecture/migrate-architecture.js +534 -0
  141. package/src/scripts/architecture/migrate-architecture.js.map +1 -0
  142. package/src/scripts/architecture/migrate-architecture.ts +653 -0
  143. package/src/scripts/architecture-graph.d.ts +368 -0
  144. package/src/scripts/architecture-graph.js +2191 -0
  145. package/src/scripts/architecture-graph.js.map +1 -0
  146. package/src/scripts/architecture-graph.ts +3136 -0
  147. package/src/scripts/components/migrate-components.d.ts +20 -0
  148. package/src/scripts/components/migrate-components.js +163 -0
  149. package/src/scripts/components/migrate-components.js.map +1 -0
  150. package/src/scripts/components/migrate-components.ts +227 -0
  151. package/src/scripts/components/migration-diagnostic.d.ts +7 -0
  152. package/src/scripts/components/migration-diagnostic.js +2 -0
  153. package/src/scripts/components/migration-diagnostic.js.map +1 -0
  154. package/src/scripts/components/migration-diagnostic.ts +8 -0
  155. package/src/scripts/create/create-project.d.ts +20 -0
  156. package/src/scripts/create/create-project.js +783 -0
  157. package/src/scripts/create/create-project.js.map +1 -0
  158. package/src/scripts/create/create-project.ts +849 -0
  159. package/src/scripts/data-flow-graph.d.ts +36 -0
  160. package/src/scripts/data-flow-graph.js +186 -0
  161. package/src/scripts/data-flow-graph.js.map +1 -0
  162. package/src/scripts/data-flow-graph.ts +240 -0
  163. package/src/scripts/dependency-graph.d.ts +152 -0
  164. package/src/scripts/dependency-graph.js +4456 -0
  165. package/src/scripts/dependency-graph.js.map +1 -0
  166. package/src/scripts/dependency-graph.ts +5908 -0
  167. package/src/scripts/effect-dependency-graph.d.ts +89 -0
  168. package/src/scripts/effect-dependency-graph.js +487 -0
  169. package/src/scripts/effect-dependency-graph.js.map +1 -0
  170. package/src/scripts/effect-dependency-graph.ts +630 -0
  171. package/src/scripts/migrate.d.ts +37 -0
  172. package/src/scripts/migrate.js +98 -0
  173. package/src/scripts/migrate.js.map +1 -0
  174. package/src/scripts/migrate.ts +162 -0
  175. package/src/scripts/migration-workspace.d.ts +2 -0
  176. package/src/scripts/migration-workspace.js +67 -0
  177. package/src/scripts/migration-workspace.js.map +1 -0
  178. package/src/scripts/migration-workspace.ts +93 -0
  179. package/src/scripts/primitives/migrate-named-primitives.d.ts +50 -0
  180. package/src/scripts/primitives/migrate-named-primitives.js +487 -0
  181. package/src/scripts/primitives/migrate-named-primitives.js.map +1 -0
  182. package/src/scripts/primitives/migrate-named-primitives.ts +627 -0
  183. package/src/scripts/primitives/migrate-primitive-generators.d.ts +30 -0
  184. package/src/scripts/primitives/migrate-primitive-generators.js +309 -0
  185. package/src/scripts/primitives/migrate-primitive-generators.js.map +1 -0
  186. package/src/scripts/primitives/migrate-primitive-generators.ts +410 -0
  187. package/src/scripts/primitives/migrate-primitives.d.ts +27 -0
  188. package/src/scripts/primitives/migrate-primitives.js +427 -0
  189. package/src/scripts/primitives/migrate-primitives.js.map +1 -0
  190. package/src/scripts/primitives/migrate-primitives.ts +655 -0
  191. package/src/scripts/primitives/migrate-yieldable-reactive-reads.d.ts +23 -0
  192. package/src/scripts/primitives/migrate-yieldable-reactive-reads.js +382 -0
  193. package/src/scripts/primitives/migrate-yieldable-reactive-reads.js.map +1 -0
  194. package/src/scripts/primitives/migrate-yieldable-reactive-reads.ts +499 -0
  195. package/src/scripts/primitives/migration-diagnostic.d.ts +8 -0
  196. package/src/scripts/primitives/migration-diagnostic.js +2 -0
  197. package/src/scripts/primitives/migration-diagnostic.js.map +1 -0
  198. package/src/scripts/primitives/migration-diagnostic.ts +15 -0
  199. package/src/scripts/routes/migrate-routes.d.ts +34 -0
  200. package/src/scripts/routes/migrate-routes.js +657 -0
  201. package/src/scripts/routes/migrate-routes.js.map +1 -0
  202. package/src/scripts/routes/migrate-routes.ts +895 -0
  203. package/src/scripts/routes/migration-diagnostic.d.ts +16 -0
  204. package/src/scripts/routes/migration-diagnostic.js +2 -0
  205. package/src/scripts/routes/migration-diagnostic.js.map +1 -0
  206. package/src/scripts/routes/migration-diagnostic.ts +25 -0
  207. package/src/scripts/routes/route-command.d.ts +75 -0
  208. package/src/scripts/routes/route-command.js +1169 -0
  209. package/src/scripts/routes/route-command.js.map +1 -0
  210. package/src/scripts/routes/route-command.ts +1807 -0
  211. package/src/scripts/routes/verify-routes.d.ts +60 -0
  212. package/src/scripts/routes/verify-routes.js +1121 -0
  213. package/src/scripts/routes/verify-routes.js.map +1 -0
  214. package/src/scripts/routes/verify-routes.ts +1511 -0
  215. package/src/scripts/services/config.d.ts +2 -0
  216. package/src/scripts/services/config.js +38 -0
  217. package/src/scripts/services/config.js.map +1 -0
  218. package/src/scripts/services/config.ts +60 -0
  219. package/src/scripts/services/migrate-services.d.ts +29 -0
  220. package/src/scripts/services/migrate-services.js +924 -0
  221. package/src/scripts/services/migrate-services.js.map +1 -0
  222. package/src/scripts/services/migrate-services.ts +1348 -0
  223. package/src/scripts/services/migration-diagnostic.d.ts +8 -0
  224. package/src/scripts/services/migration-diagnostic.js +2 -0
  225. package/src/scripts/services/migration-diagnostic.js.map +1 -0
  226. package/src/scripts/services/migration-diagnostic.ts +28 -0
  227. package/src/scripts/template/migrate-template.d.ts +16 -0
  228. package/src/scripts/template/migrate-template.js +349 -0
  229. package/src/scripts/template/migrate-template.js.map +1 -0
  230. package/src/scripts/template/migrate-template.ts +418 -0
  231. package/src/template-migration.d.ts +1 -0
  232. package/src/template-migration.js +2 -0
  233. package/src/template-migration.js.map +1 -0
  234. package/src/testing.d.ts +19 -0
  235. package/src/testing.js +2 -0
  236. package/src/testing.js.map +1 -0
  237. package/src/tsconfig.codemod.json +13 -0
@@ -0,0 +1,4456 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, relative, resolve } from 'node:path';
5
+ import { Node, Project, SyntaxKind, } from 'ts-morph';
6
+ import { architectureCatalogToTypeScript, buildArchitectureCatalog, } from './architecture-graph.js';
7
+ import { collectEffectLayers, collectEffectOperationOwners, collectEffectServices, collectEffectServiceUsage, } from './effect-dependency-graph.js';
8
+ import { collectDataFlowGraph } from './data-flow-graph.js';
9
+ const PRIMITIVES = new Set([
10
+ 'state',
11
+ 'query',
12
+ 'mutation',
13
+ 'queryEffect',
14
+ 'computedEffect',
15
+ 'mutationEffect',
16
+ 'asyncProcess',
17
+ 'queryParams',
18
+ 'insertSelect',
19
+ 'craftStateMachine',
20
+ 'craftComputed',
21
+ 'craftEffect',
22
+ 'craftMethod',
23
+ ]);
24
+ // A host owns the primitives declared inside it: they hang off the host in the
25
+ // graph instead of flattening into the component or service around it.
26
+ const HOST_PRIMITIVES = new Set([
27
+ 'state',
28
+ 'query',
29
+ 'mutation',
30
+ 'queryEffect',
31
+ 'computedEffect',
32
+ 'mutationEffect',
33
+ 'asyncProcess',
34
+ 'queryParams',
35
+ 'insertSelect',
36
+ 'craftStateMachine',
37
+ ]);
38
+ const STORAGE_PERSISTER_INSERTIONS = new Set([
39
+ 'insertStoragePersister',
40
+ 'insertLocalStoragePersister',
41
+ ]);
42
+ const SOURCE_CREATORS = new Set(['source$', 'signalSource']);
43
+ const CRAFT_HTTP_CLIENT_METHODS = new Set([
44
+ 'get',
45
+ 'delete',
46
+ 'post',
47
+ 'put',
48
+ 'patch',
49
+ 'request',
50
+ ]);
51
+ const CRAFT_TEMPORAL_FUNCTIONS = new Set([
52
+ 'craftSleep',
53
+ 'setTimeout',
54
+ 'setInterval',
55
+ 'clearTimeout',
56
+ 'clearInterval',
57
+ 'requestAnimationFrame',
58
+ 'cancelAnimationFrame',
59
+ ]);
60
+ const CRAFT_TEMPORAL_RUNTIME_METHODS = new Set(['sleep', 'schedule', 'cancel']);
61
+ const NON_DEPENDENCY_PROPERTY_NAMES = new Set([
62
+ 'map',
63
+ 'filter',
64
+ 'find',
65
+ 'findIndex',
66
+ 'slice',
67
+ 'join',
68
+ 'includes',
69
+ 'indexOf',
70
+ 'push',
71
+ 'pop',
72
+ 'shift',
73
+ 'unshift',
74
+ ]);
75
+ const REACTIVE_READER_NAMES = new Set([
76
+ 'state',
77
+ 'resource',
78
+ 'value',
79
+ 'isLoading',
80
+ 'hasValue',
81
+ 'hasException',
82
+ 'exceptions',
83
+ 'settledValue',
84
+ 'status',
85
+ 'currentPageStatus',
86
+ 'currentTerm',
87
+ ]);
88
+ const REACTIVE_METHOD_NAMES = new Set([
89
+ 'mutate',
90
+ 'set',
91
+ 'update',
92
+ 'patch',
93
+ 'increment',
94
+ 'decrement',
95
+ 'emit',
96
+ 'reset',
97
+ 'clear',
98
+ 'reload',
99
+ ]);
100
+ const REACTIVE_WRAPPER_NAMES = new Set(['settled', 'craftUse']);
101
+ const INSERTION_CONTEXT_NAMES = new Set([
102
+ 'state',
103
+ 'resource',
104
+ 'set',
105
+ 'update',
106
+ 'patch',
107
+ 'hasException',
108
+ 'hasValue',
109
+ 'exceptions',
110
+ 'value',
111
+ ]);
112
+ export function analyzeDependencyGraph(options = {}) {
113
+ const rootDir = resolve(options.rootDir ?? process.cwd());
114
+ const tsConfigFilePath = resolve(rootDir, options.tsConfigFilePath ?? detectTsConfig(rootDir));
115
+ const project = new Project({ tsConfigFilePath });
116
+ const builder = {
117
+ project,
118
+ rootDir,
119
+ graph: {
120
+ version: 1,
121
+ rootDir,
122
+ tsConfigFilePath,
123
+ nodes: [],
124
+ edges: [],
125
+ diagnostics: [],
126
+ },
127
+ nodes: new Map(),
128
+ edges: new Map(),
129
+ services: [],
130
+ components: [],
131
+ sources: [],
132
+ routeFiles: new Map(),
133
+ appConfigs: [],
134
+ serviceByHelperKey: new Map(),
135
+ servicesByHelperName: new Map(),
136
+ componentByVariable: new Map(),
137
+ componentByVariableKey: new Map(),
138
+ componentsByVariableName: new Map(),
139
+ diagnostics: [],
140
+ middlewareCapabilities: options.middlewareCapabilities ?? {},
141
+ };
142
+ const sourceFiles = project
143
+ .getSourceFiles()
144
+ .filter((sourceFile) => !sourceFile.isDeclarationFile())
145
+ .filter((sourceFile) => options.include?.length
146
+ ? options.include.some((pattern) => sourceFile.getFilePath().includes(pattern))
147
+ : true);
148
+ collectServices(builder, sourceFiles);
149
+ collectSources(builder, sourceFiles);
150
+ collectComponents(builder, sourceFiles);
151
+ collectRoutes(builder, sourceFiles);
152
+ collectAppConfigs(builder, sourceFiles);
153
+ // Server-function nodes must exist before service/component analysis so
154
+ // loader calls can be linked directly to their family node.
155
+ collectServerFunctions(builder, sourceFiles);
156
+ collectServerFunctionMiddlewares(builder, sourceFiles);
157
+ collectClientFunctionMiddlewares(builder, sourceFiles);
158
+ collectHandshakes(builder, sourceFiles);
159
+ analyzeServiceBodies(builder);
160
+ analyzeComponents(builder);
161
+ collectInteractiveTemplateElements(builder);
162
+ analyzeRoutes(builder);
163
+ analyzeInsertions(builder);
164
+ collectCraftUniques(builder, sourceFiles);
165
+ collectRouteChecks(builder);
166
+ collectEffectGraph(builder, sourceFiles);
167
+ linkEffectLoaderRequirements(builder);
168
+ builder.graph.nodes = [...builder.nodes.values()];
169
+ builder.graph.edges = [...builder.edges.values()];
170
+ for (const collector of options.collectors ?? []) {
171
+ const contribution = collector.collect({
172
+ project,
173
+ rootDir,
174
+ sourceFiles,
175
+ graph: builder.graph,
176
+ });
177
+ mergeCollectorContribution(builder, collector.name, contribution);
178
+ builder.graph.nodes = [...builder.nodes.values()];
179
+ builder.graph.edges = [...builder.edges.values()];
180
+ }
181
+ builder.graph.nodes = [...builder.nodes.values()].sort((left, right) => left.id.localeCompare(right.id));
182
+ builder.graph.edges = [...builder.edges.values()].sort((left, right) => `${left.from}:${left.kind}:${left.to}`.localeCompare(`${right.from}:${right.kind}:${right.to}`));
183
+ if (builder.diagnostics.length > 0) {
184
+ builder.graph.diagnostics = [...builder.diagnostics];
185
+ }
186
+ return builder.graph;
187
+ }
188
+ export async function writeDependencyGraph(options) {
189
+ const graph = analyzeDependencyGraph(options);
190
+ const format = options.format ?? 'both';
191
+ const outputPath = resolve(options.rootDir ?? process.cwd(), options.outputPath);
192
+ await mkdir(dirname(outputPath), { recursive: true });
193
+ if (format === 'json' || format === 'both' || format === 'all') {
194
+ const jsonPath = format === 'json' ? outputPath : `${outputPath}.json`;
195
+ await writeFile(jsonPath, `${JSON.stringify(graph, null, 2)}\n`, 'utf8');
196
+ const catalogPath = `${outputPath.replace(/\.(json|html|mmd)$/i, '')}.architecture.ts`;
197
+ await writeFile(catalogPath, architectureCatalogToTypeScript(buildArchitectureCatalog(graph)), 'utf8');
198
+ }
199
+ if (format === 'mermaid' || format === 'both' || format === 'all') {
200
+ const mermaidPath = format === 'mermaid' ? outputPath : `${outputPath}.mmd`;
201
+ await writeFile(mermaidPath, `${dependencyGraphToMermaid(graph)}\n`, 'utf8');
202
+ }
203
+ if (format === 'html' || format === 'all') {
204
+ const htmlPath = format === 'html' ? outputPath : `${outputPath}.html`;
205
+ await writeFile(htmlPath, dependencyGraphToHtml(graph), 'utf8');
206
+ }
207
+ return graph;
208
+ }
209
+ export function dependencyGraphToMermaid(graph) {
210
+ const lines = ['graph TD'];
211
+ const ids = new Map();
212
+ const labelCounts = new Map();
213
+ for (const node of graph.nodes) {
214
+ const key = `${node.kind}:${node.label}`;
215
+ labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
216
+ }
217
+ let nextId = 0;
218
+ const getId = (nodeId) => {
219
+ const existing = ids.get(nodeId);
220
+ if (existing)
221
+ return existing;
222
+ const id = `n${nextId++}`;
223
+ ids.set(nodeId, id);
224
+ return id;
225
+ };
226
+ for (const node of graph.nodes) {
227
+ const id = getId(node.id);
228
+ const duplicate = (labelCounts.get(`${node.kind}:${node.label}`) ?? 0) > 1;
229
+ const location = node.filePath
230
+ ? relative(graph.rootDir, node.filePath).split('\\').join('/')
231
+ : undefined;
232
+ const displayLabel = duplicate && location ? `${node.label} — ${location}` : node.label;
233
+ const label = escapeMermaid(displayLabel);
234
+ if (node.kind === 'route') {
235
+ lines.push(` ${id}{{"${label}"}}`);
236
+ }
237
+ else if (node.kind === 'app-config') {
238
+ lines.push(` ${id}[/"${label}"/]`);
239
+ }
240
+ else if (node.kind === 'route-check') {
241
+ lines.push(` ${id}[["${label}"]]`);
242
+ }
243
+ else if (node.kind === 'service') {
244
+ lines.push(` ${id}(["${label}"])`);
245
+ }
246
+ else {
247
+ lines.push(` ${id}["${label}"]`);
248
+ }
249
+ }
250
+ for (const edge of graph.edges) {
251
+ lines.push(` ${getId(edge.from)} -->|${escapeMermaid(edge.kind)}| ${getId(edge.to)}`);
252
+ }
253
+ return lines.join('\n');
254
+ }
255
+ /**
256
+ * Creates a self-contained, static explorer. The graph is embedded in the file
257
+ * so opening the HTML never needs the source tree, a server, or application
258
+ * runtime instrumentation.
259
+ */
260
+ export function dependencyGraphToHtml(graph) {
261
+ const serializedGraph = JSON.stringify(graph)
262
+ .replace(/</g, '\\u003c')
263
+ .replace(/>/g, '\\u003e')
264
+ .replace(/&/g, '\\u0026');
265
+ return `<!doctype html>
266
+ <html lang="fr">
267
+ <head>
268
+ <meta charset="utf-8">
269
+ <meta name="viewport" content="width=device-width, initial-scale=1">
270
+ <title>CraftTS — Dependency Explorer</title>
271
+ <style>
272
+ :root {
273
+ color-scheme: light;
274
+ --bg: #f5f7fb;
275
+ --panel: #ffffff;
276
+ --panel-soft: #f8fafc;
277
+ --line: #dce3ee;
278
+ --text: #172033;
279
+ --muted: #68758a;
280
+ --accent: #315efb;
281
+ --accent-soft: #e8edff;
282
+ --warning: #a15c00;
283
+ --warning-soft: #fff4df;
284
+ --shadow: 0 12px 35px rgba(25, 38, 65, .08);
285
+ }
286
+ * { box-sizing: border-box; }
287
+ html, body { margin: 0; min-height: 100%; font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--text); background: var(--bg); }
288
+ button, input { font: inherit; }
289
+ button { color: inherit; }
290
+ .app { min-height: 100vh; display: grid; grid-template-columns: 280px minmax(440px, 1fr) 360px; grid-template-rows: auto minmax(0, 1fr); }
291
+ .topbar { grid-column: 1 / -1; display: flex; align-items: center; gap: 22px; padding: 14px 20px; background: var(--panel); border-bottom: 1px solid var(--line); }
292
+ .brand { min-width: 250px; }
293
+ .brand h1 { margin: 0; font-size: 17px; letter-spacing: -.02em; }
294
+ .brand p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
295
+ .search { flex: 1; max-width: 620px; position: relative; }
296
+ .search input { width: 100%; padding: 10px 13px 10px 36px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel-soft); outline: none; }
297
+ .search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
298
+ .search span { position: absolute; left: 13px; top: 9px; color: var(--muted); }
299
+ .stats { display: flex; gap: 15px; margin-left: auto; color: var(--muted); white-space: nowrap; font-size: 12px; }
300
+ .stats strong { display: block; color: var(--text); font-size: 16px; line-height: 1.1; }
301
+ .sidebar, .details { min-height: 0; overflow: auto; background: var(--panel); }
302
+ .sidebar { border-right: 1px solid var(--line); padding: 18px 14px; }
303
+ .details { border-left: 1px solid var(--line); padding: 20px; }
304
+ .side-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 7px 10px; }
305
+ .side-title h2 { margin: 0; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
306
+ .route-list { display: grid; gap: 5px; }
307
+ .route-button { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; width: 100%; padding: 9px 10px; text-align: left; border: 1px solid transparent; border-radius: 8px; background: transparent; cursor: pointer; }
308
+ .route-button:hover { background: var(--panel-soft); }
309
+ .route-button.active { background: var(--accent-soft); border-color: #cbd5ff; color: #173bb8; }
310
+ .route-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
311
+ .route-meta { display: flex; gap: 5px; align-items: center; color: var(--muted); font-size: 11px; }
312
+ .badge { display: inline-flex; align-items: center; min-height: 19px; padding: 1px 6px; border-radius: 999px; background: #edf1f7; color: var(--muted); font-size: 11px; white-space: nowrap; }
313
+ .badge.shared { background: var(--warning-soft); color: var(--warning); }
314
+ .workspace { min-width: 0; min-height: 0; overflow: auto; padding: 22px 26px 40px; }
315
+ .workspace-head { display: flex; align-items: start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
316
+ .workspace-head h2 { margin: 0; font-size: 20px; letter-spacing: -.025em; }
317
+ .workspace-head p { margin: 4px 0 0; color: var(--muted); }
318
+ .filter-bar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
319
+ .filter { border: 1px solid var(--line); background: var(--panel); border-radius: 999px; padding: 5px 10px; cursor: pointer; color: var(--muted); }
320
+ .filter.active { color: var(--accent); border-color: #b9c5ff; background: var(--accent-soft); }
321
+ .legend { display: flex; flex-wrap: wrap; gap: 12px; margin: 0 0 16px 2px; color: var(--muted); font-size: 11px; }
322
+ .legend-item { display: inline-flex; align-items: center; gap: 5px; }
323
+ .legend-line { display: inline-block; width: 22px; height: 3px; border-radius: 99px; }
324
+ .legend-line.template { background: #7c4dff; }
325
+ .legend-line.setup { background: #72a9d2; }
326
+ .legend-line.both { background: #ae70c7; }
327
+ .legend-line.calls { background: #e67e22; }
328
+ .legend-line.depends { background: #00a884; }
329
+ .tree { display: grid; gap: 12px; }
330
+ .graph-scroll { overflow-x: auto; overflow-y: hidden; margin: 0 -8px; padding: 8px; border: 1px solid var(--line); border-radius: 12px; background: #eef2f8; }
331
+ .graph-canvas { position: relative; display: grid; grid-template-columns: repeat(5, minmax(220px, 255px)); gap: 52px; min-width: 1530px; min-height: 620px; padding: 26px 28px 42px; }
332
+ .graph-edges { position: absolute; z-index: 5; inset: 0; overflow: visible; pointer-events: none; }
333
+ .graph-edges path { fill: none; stroke: #aebbd0; stroke-width: 1.7; opacity: .82; }
334
+ .graph-edges path.edge-depends-on { stroke: #00a884; stroke-width: 3.2; }
335
+ .graph-edges path.edge-provides { stroke: #7c3aed; stroke-width: 2.4; stroke-dasharray: 4 3; }
336
+ .graph-edges path.edge-primitive-member { stroke: #a7d8ca; stroke-width: 1.8; opacity: .78; }
337
+ .graph-edges path.edge-contains { stroke: #d79a22; stroke-dasharray: 5 3; }
338
+ .graph-edges path.edge-checks { stroke: #0f766e; stroke-dasharray: 2 3; }
339
+ .graph-edges path.edge-uses-property { stroke: #7c4dff; }
340
+ .graph-edges path.edge-uses-property.edge-template { stroke: #7c4dff; }
341
+ .graph-edges path.edge-uses-property.edge-setup { stroke: #72a9d2; }
342
+ .graph-edges path.edge-uses-property.edge-both { stroke: #ae70c7; }
343
+ .graph-edges path.edge-reads, .graph-edges path.edge-writes, .graph-edges path.edge-subscribes, .graph-edges path.edge-triggers { stroke: #1292c9; stroke-dasharray: 3 3; }
344
+ .graph-edges path.edge-calls { stroke: #e67e22; stroke-width: 2.2; }
345
+ .graph-column { position: relative; display: grid; align-content: start; gap: 13px; min-width: 0; }
346
+ .graph-column-title { position: sticky; top: 0; z-index: 3; padding: 6px 8px; border-bottom: 1px solid #cfd8e6; color: var(--muted); background: rgba(238, 242, 248, .94); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
347
+ .graph-card { position: relative; z-index: 6; display: grid; gap: 5px; min-width: 0; min-height: 68px; padding: 10px 12px; border: 1px solid var(--line); border-left: 4px solid #8a96a8; border-radius: 9px; background: var(--panel); box-shadow: 0 4px 12px rgba(25, 38, 65, .08); text-align: left; cursor: pointer; }
348
+ .graph-card:hover, .graph-card.selected { border-color: #9eafff; box-shadow: 0 0 0 3px var(--accent-soft), 0 5px 14px rgba(25, 38, 65, .09); }
349
+ .graph-card .node-kind { min-width: 0; }
350
+ .graph-card .node-label { display: block; white-space: normal; overflow-wrap: anywhere; }
351
+ .graph-card .node-file { display: block; margin: 0; white-space: normal; overflow-wrap: anywhere; }
352
+ .graph-card.kind-route { border-left-color: #315efb; }
353
+ .graph-card.kind-component { border-left-color: #7c4dff; }
354
+ .graph-card.kind-service { border-left-color: #00a884; }
355
+ .graph-card.kind-property { border-left-color: #e19a00; }
356
+ .graph-card.kind-primitive { border-left-color: #e05d8f; }
357
+ .graph-card.kind-source { border-left-color: #1292c9; }
358
+ .graph-card.kind-http-endpoint { border-left-color: #0f766e; }
359
+ .graph-card.kind-unique { border-left-color: #c026d3; }
360
+ .graph-card.kind-route-hook { border-left-color: #7d8798; }
361
+ .graph-card.kind-app-config { border-left-color: #0369a1; }
362
+ .graph-card .card-topline { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
363
+ .graph-card .card-topline .badge { margin-left: auto; }
364
+ .graph-block { display: grid; gap: 8px; padding: 10px; border: 1px solid #cfd9e8; border-radius: 12px; background: rgba(255, 255, 255, .72); box-shadow: 0 4px 12px rgba(25, 38, 65, .045); }
365
+ .graph-block > .graph-card { box-shadow: 0 3px 9px rgba(25, 38, 65, .08); }
366
+ .component-parts { display: grid; gap: 8px; }
367
+ .component-part { display: grid; gap: 6px; padding: 8px; border: 1px solid #dce4ef; border-radius: 9px; background: rgba(248, 250, 253, .92); }
368
+ .component-part-title { color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
369
+ .component-anchor { position: relative; z-index: 6; display: flex; align-items: center; min-height: 28px; padding: 5px 8px; border: 1px dashed #b9c9dc; border-radius: 6px; background: #fff; color: #52647d; font-size: 11px; font-weight: 700; }
370
+ .component-empty { color: #9aa7b8; font-size: 11px; font-style: italic; }
371
+ .graph-internals { display: grid; gap: 6px; margin: 0 0 1px 15px; padding: 9px 8px 8px 11px; border-left: 2px solid #cbd6e5; border-radius: 0 8px 8px 0; background: #f5f8fc; }
372
+ .internal-title { color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
373
+ .internal-node { display: grid; gap: 4px; }
374
+ .internal-relation { color: var(--muted); font-size: 10px; }
375
+ .internal-node .graph-card { min-height: 54px; padding: 8px 9px; box-shadow: none; }
376
+ .internal-node .graph-card .node-file { font-size: 10px; }
377
+ .tree-item { position: relative; }
378
+ .tree-item.depth-1 { margin-left: 22px; }
379
+ .tree-item.depth-2 { margin-left: 44px; }
380
+ .tree-item.depth-3 { margin-left: 66px; }
381
+ .tree-item.depth-4 { margin-left: 88px; }
382
+ .tree-item.depth-5 { margin-left: 110px; }
383
+ .tree-item:before { content: ""; position: absolute; left: -14px; top: 0; bottom: 0; border-left: 1px solid var(--line); }
384
+ .tree-item.depth-0:before { display: none; }
385
+ .tree-children { display: grid; gap: 7px; margin: 9px 0 0 42px; padding: 10px 12px 11px 14px; border: 1px solid #dfe6f1; border-left: 3px solid #c5d0e0; border-radius: 0 11px 11px 0; background: linear-gradient(90deg, #f8faff, #ffffff); }
386
+ .tree-children > .tree-item { margin-left: 0; }
387
+ .tree-children > .tree-item:before { left: -16px; top: -8px; bottom: -8px; border-color: #c5d0e0; }
388
+ .relation-group { display: grid; gap: 6px; padding-top: 2px; }
389
+ .relation-group + .relation-group { margin-top: 7px; padding-top: 10px; border-top: 1px dashed #d8e0ec; }
390
+ .group-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
391
+ .group-title span:last-child { min-width: 19px; padding: 1px 5px; border-radius: 999px; background: #e9eef6; text-align: center; font-size: 10px; letter-spacing: 0; }
392
+ .node-row { display: flex; align-items: stretch; gap: 6px; min-width: 0; }
393
+ .toggle { width: 28px; border: 0; background: transparent; color: var(--muted); cursor: pointer; }
394
+ .toggle.empty { visibility: hidden; }
395
+ .node { flex: 1; min-width: 0; display: flex; align-items: center; gap: 9px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); box-shadow: 0 2px 8px rgba(25, 38, 65, .035); text-align: left; cursor: pointer; }
396
+ .node:hover, .node.selected { border-color: #9eafff; box-shadow: 0 0 0 3px var(--accent-soft); }
397
+ .node-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
398
+ .node-kind { flex: 0 0 auto; min-width: 76px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .07em; }
399
+ .node-file { min-width: 0; margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11px; }
400
+ .kind-route { border-left: 4px solid #315efb; }
401
+ .kind-component { border-left: 4px solid #7c4dff; }
402
+ .kind-service { border-left: 4px solid #00a884; }
403
+ .kind-property { border-left: 4px solid #e19a00; }
404
+ .kind-primitive { border-left: 4px solid #e05d8f; }
405
+ .kind-source { border-left: 4px solid #1292c9; }
406
+ .kind-unique { border-left: 4px solid #c026d3; }
407
+ .kind-route-hook { border-left: 4px solid #7d8798; }
408
+ .kind-app-config { border-left: 4px solid #0369a1; }
409
+ .edge-label { display: inline-flex; width: fit-content; margin: 0 0 -2px 0; padding: 1px 7px; border: 1px solid #dbe3ef; border-radius: 999px; background: #f1f5fa; color: var(--muted); font-size: 10px; }
410
+ .empty { padding: 28px 14px; text-align: center; color: var(--muted); }
411
+ .detail-empty { color: var(--muted); padding-top: 50px; text-align: center; }
412
+ .detail-head { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--line); }
413
+ .detail-head h2 { margin: 6px 0 3px; font-size: 19px; overflow-wrap: anywhere; }
414
+ .detail-head p { margin: 0; color: var(--muted); overflow-wrap: anywhere; }
415
+ .detail-section { margin: 18px 0; }
416
+ .detail-section h3 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
417
+ .callout { padding: 11px 12px; border: 1px solid #f0d29a; border-radius: 9px; background: var(--warning-soft); color: var(--warning); }
418
+ .detail-list { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; }
419
+ .detail-list li { display: flex; gap: 6px; align-items: baseline; min-width: 0; }
420
+ .detail-list button { min-width: 0; padding: 0; border: 0; color: var(--accent); background: none; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
421
+ .detail-list button:hover { text-decoration: underline; }
422
+ .relation-kind { flex: 0 0 auto; color: var(--muted); font-size: 11px; }
423
+ .meta-table { width: 100%; border-collapse: collapse; font-size: 12px; }
424
+ .meta-table th, .meta-table td { padding: 5px 0; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
425
+ .meta-table th { width: 40%; color: var(--muted); font-weight: 500; }
426
+ .meta-table td { overflow-wrap: anywhere; }
427
+ @media (max-width: 1100px) { .app { grid-template-columns: 230px minmax(360px, 1fr); } .details { grid-column: 1 / -1; border: 0; border-top: 1px solid var(--line); max-height: 420px; } }
428
+ @media (max-width: 720px) { .app { display: block; } .topbar { flex-wrap: wrap; } .brand { min-width: 0; } .stats { margin-left: 0; } .sidebar, .details { max-height: none; border: 0; border-bottom: 1px solid var(--line); } .workspace { padding: 18px 14px 30px; } .node-file { display: none; } }
429
+ </style>
430
+ </head>
431
+ <body>
432
+ <div class="app">
433
+ <header class="topbar">
434
+ <div class="brand"><h1>CraftTS · Dependency Explorer</h1><p>Analyse statique AST + typage TypeScript</p></div>
435
+ <label class="search"><span>⌕</span><input id="search" type="search" placeholder="Rechercher une route, un composant, un service…" aria-label="Rechercher"></label>
436
+ <div class="stats" id="stats"></div>
437
+ </header>
438
+ <aside class="sidebar">
439
+ <div class="side-title"><h2>Routes</h2><span class="badge" id="route-count"></span></div>
440
+ <div class="route-list" id="routes"></div>
441
+ </aside>
442
+ <main class="workspace">
443
+ <div class="workspace-head"><div><h2 id="route-title">Routes</h2><p id="route-subtitle">Sélectionnez une route pour explorer ses dépendances.</p></div></div>
444
+ <div class="filter-bar" id="filters"></div>
445
+ <div class="legend"><span class="legend-item"><span class="legend-line template"></span>Template</span><span class="legend-item"><span class="legend-line setup"></span>Setup</span><span class="legend-item"><span class="legend-line both"></span>Template + setup</span><span class="legend-item"><span class="legend-line depends"></span>Dépendance computed / state</span><span class="legend-item"><span class="legend-line calls"></span>Appel de méthode</span></div>
446
+ <div class="tree" id="tree"></div>
447
+ </main>
448
+ <aside class="details" id="details"></aside>
449
+ </div>
450
+ <script>
451
+ const GRAPH = ${serializedGraph};
452
+ const nodes = new Map(GRAPH.nodes.map(function (node) { return [node.id, node]; }));
453
+ const outgoing = new Map();
454
+ const incoming = new Map();
455
+ GRAPH.edges.forEach(function (edge) {
456
+ if (!outgoing.has(edge.from)) outgoing.set(edge.from, []);
457
+ if (!incoming.has(edge.to)) incoming.set(edge.to, []);
458
+ outgoing.get(edge.from).push(edge);
459
+ incoming.get(edge.to).push(edge);
460
+ });
461
+ const routes = GRAPH.nodes.filter(function (node) { return node.kind === 'route'; });
462
+ const services = GRAPH.nodes.filter(function (node) { return node.kind === 'service'; });
463
+ const routeReachability = new Map();
464
+ const serviceRoutes = new Map();
465
+ const state = { routeId: routes[0] && routes[0].id, selectedId: routes[0] && routes[0].id, filter: 'all', search: '', expanded: new Set() };
466
+ const filters = [['all', 'Tout'], ['component', 'Composants'], ['service', 'Services'], ['primitive', 'Primitives'], ['source', 'Sources'], ['unique', 'Uniques'], ['route-check', 'Preuves']];
467
+
468
+ function esc(value) {
469
+ return String(value == null ? '' : value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
470
+ }
471
+ function relativePath(filePath) {
472
+ if (!filePath) return '';
473
+ const rootPath = GRAPH.rootDir.split(String.fromCharCode(92)).join('/');
474
+ const root = rootPath.endsWith('/') ? rootPath.slice(0, -1) : rootPath;
475
+ const path = filePath.split(String.fromCharCode(92)).join('/');
476
+ return path.indexOf(root + '/') === 0 ? path.slice(root.length + 1) : path;
477
+ }
478
+ function kindLabel(kind) {
479
+ return ({ 'route': 'route', 'route-hook': 'hook', 'route-check': 'preuve', 'app-config': 'app config', 'component': 'composant', 'service': 'service', 'property': 'champ', 'primitive': 'primitive', 'source': 'source$', 'http-endpoint': 'http', 'unique': 'unique' })[kind] || kind;
480
+ }
481
+ function displayLabel(node) {
482
+ if (!node) return '';
483
+ return node.label;
484
+ }
485
+ function searchMatches(node) {
486
+ if (!state.search) return true;
487
+ const haystack = [node.label, node.kind, relativePath(node.filePath)].join(' ').toLowerCase();
488
+ return haystack.indexOf(state.search.toLowerCase()) >= 0;
489
+ }
490
+ function relatedNodeIds(routeId) {
491
+ const result = new Set([routeId]);
492
+ const queue = [routeId];
493
+ while (queue.length) {
494
+ const current = queue.shift();
495
+ (outgoing.get(current) || []).forEach(function (edge) {
496
+ if (result.has(edge.to)) return;
497
+ result.add(edge.to);
498
+ queue.push(edge.to);
499
+ });
500
+ (incoming.get(current) || []).forEach(function (edge) {
501
+ if (edge.kind !== 'checks' || result.has(edge.from)) return;
502
+ result.add(edge.from);
503
+ queue.push(edge.from);
504
+ });
505
+ }
506
+ return result;
507
+ }
508
+ routes.forEach(function (route) {
509
+ const ids = relatedNodeIds(route.id);
510
+ routeReachability.set(route.id, ids);
511
+ ids.forEach(function (id) {
512
+ const node = nodes.get(id);
513
+ if (node && node.kind === 'service') {
514
+ if (!serviceRoutes.has(id)) serviceRoutes.set(id, []);
515
+ serviceRoutes.get(id).push(route);
516
+ }
517
+ });
518
+ });
519
+ function serviceBadge(node) {
520
+ const users = serviceRoutes.get(node.id) || [];
521
+ return users.length > 1 ? '<span class="badge shared">partagé · ' + users.length + ' routes</span>' : '';
522
+ }
523
+ function edgeKinds(parentId, childId) {
524
+ const outgoingKinds = (outgoing.get(parentId) || []).filter(function (edge) { return edge.to === childId; }).map(function (edge) { return edge.kind; });
525
+ const incomingChecks = (incoming.get(parentId) || []).filter(function (edge) { return edge.from === childId && edge.kind === 'checks'; }).map(function () { return 'checks'; });
526
+ return [...new Set(outgoingKinds.concat(incomingChecks))];
527
+ }
528
+ function childIds(nodeId) {
529
+ const seen = new Set();
530
+ const fromOutgoing = (outgoing.get(nodeId) || []).map(function (edge) { return edge.to; });
531
+ const fromChecks = (incoming.get(nodeId) || []).filter(function (edge) { return edge.kind === 'checks'; }).map(function (edge) { return edge.from; });
532
+ return fromOutgoing.concat(fromChecks).filter(function (id) {
533
+ if (seen.has(id)) return false;
534
+ seen.add(id);
535
+ return true;
536
+ });
537
+ }
538
+ function relationGroupLabel(kinds) {
539
+ if (kinds.some(function (kind) { return kind === 'checks'; })) return 'Preuves DI';
540
+ if (kinds.some(function (kind) { return kind === 'loads' || kind === 'renders'; })) return 'Routes et composants';
541
+ if (kinds.some(function (kind) { return kind === 'depends-on'; })) return 'Dépendances externes';
542
+ if (kinds.some(function (kind) { return kind === 'provides'; })) return 'Providers';
543
+ if (kinds.some(function (kind) { return kind === 'uses-property'; })) return 'Champs et states utilisés';
544
+ if (kinds.some(function (kind) { return kind === 'calls'; })) return 'Méthodes appelées';
545
+ if (kinds.some(function (kind) { return kind === 'reads' || kind === 'writes' || kind === 'subscribes' || kind === 'triggers'; })) return 'Interactions source$';
546
+ if (kinds.some(function (kind) { return kind === 'contains'; })) return 'Contenu interne';
547
+ return 'Relations';
548
+ }
549
+ function childGroups(nodeId) {
550
+ const groups = new Map();
551
+ childIds(nodeId).forEach(function (childId) {
552
+ const kinds = edgeKinds(nodeId, childId);
553
+ const label = relationGroupLabel(kinds);
554
+ if (!groups.has(label)) groups.set(label, []);
555
+ groups.get(label).push({ id: childId, kinds: kinds });
556
+ });
557
+ return [...groups.entries()];
558
+ }
559
+ function selectNode(id) {
560
+ state.selectedId = id;
561
+ renderTree();
562
+ renderDetails();
563
+ }
564
+ function selectRoute(id) {
565
+ state.routeId = id;
566
+ state.selectedId = id;
567
+ state.expanded = new Set([id]);
568
+ childIds(id).forEach(function (childId) {
569
+ const child = nodes.get(childId);
570
+ if (child && child.kind === 'component') state.expanded.add(childId);
571
+ });
572
+ renderRoutes();
573
+ renderTree();
574
+ renderDetails();
575
+ }
576
+ function toggleNode(id) {
577
+ if (state.expanded.has(id)) state.expanded.delete(id); else state.expanded.add(id);
578
+ renderTree();
579
+ }
580
+ function renderRoutes() {
581
+ const container = document.getElementById('routes');
582
+ const visible = routes.filter(searchMatches);
583
+ document.getElementById('route-count').textContent = visible.length + ' / ' + routes.length;
584
+ if (!visible.length) { container.innerHTML = '<div class="empty">Aucune route trouvée.</div>'; return; }
585
+ container.innerHTML = visible.map(function (route) {
586
+ const ids = routeReachability.get(route.id) || new Set();
587
+ const shared = services.filter(function (service) { return ids.has(service.id) && (serviceRoutes.get(service.id) || []).length > 1; }).length;
588
+ return '<button class="route-button ' + (route.id === state.routeId ? 'active' : '') + '" data-route="' + esc(route.id) + '"><span class="route-label" title="' + esc(route.label) + '">' + esc(route.label) + '</span><span class="route-meta"><span>' + ids.size + '</span>' + (shared ? '<span class="badge shared">' + shared + '</span>' : '') + '</span></button>';
589
+ }).join('');
590
+ container.querySelectorAll('[data-route]').forEach(function (button) { button.addEventListener('click', function () { selectRoute(button.getAttribute('data-route')); }); });
591
+ }
592
+ function renderFilters() {
593
+ document.getElementById('filters').innerHTML = filters.map(function (item) { return '<button class="filter ' + (state.filter === item[0] ? 'active' : '') + '" data-filter="' + item[0] + '">' + item[1] + '</button>'; }).join('');
594
+ document.querySelectorAll('[data-filter]').forEach(function (button) { button.addEventListener('click', function () { state.filter = button.getAttribute('data-filter'); renderFilters(); renderTree(); }); });
595
+ }
596
+ function renderNode(nodeId, depth, trail) {
597
+ const node = nodes.get(nodeId);
598
+ if (!node || trail.has(nodeId)) return '';
599
+ const groups = childGroups(nodeId).map(function (entry) {
600
+ return [entry[0], entry[1].map(function (child) { return { node: nodes.get(child.id), kinds: child.kinds }; }).filter(function (child) { return Boolean(child.node); })];
601
+ }).filter(function (entry) { return entry[1].length > 0; });
602
+ const children = groups.reduce(function (all, entry) { return all.concat(entry[1].map(function (child) { return child.node; })); }, []);
603
+ const matching = state.filter === 'all' || node.kind === state.filter;
604
+ const isExpanded = state.expanded.has(nodeId);
605
+ const childHtml = isExpanded ? groups.map(function (group) {
606
+ return '<div class="relation-group"><div class="group-title"><span>' + esc(group[0]) + '</span><span>' + group[1].length + '</span></div>' + group[1].map(function (child) {
607
+ return '<div class="edge-label">' + esc(child.kinds.join(' · ')) + '</div>' + renderNode(child.node.id, Math.min(depth + 1, 5), new Set([...trail, nodeId]));
608
+ }).join('') + '</div>';
609
+ }).join('') : '';
610
+ if (!matching && !childHtml) return '';
611
+ const file = relativePath(node.filePath) + (node.line ? ':' + node.line : '');
612
+ const badges = node.kind === 'service' ? serviceBadge(node) : '';
613
+ const selected = state.selectedId === node.id ? ' selected' : '';
614
+ const toggleClass = children.length ? '' : ' empty';
615
+ return '<div class="tree-item depth-' + depth + '"><div class="node-row"><button class="toggle' + toggleClass + '" data-toggle="' + esc(node.id) + '" aria-label="Déplier ou replier">' + (isExpanded ? '▾' : '▸') + '</button><button class="node kind-' + esc(node.kind) + selected + '" data-node="' + esc(node.id) + '" title="' + esc(file) + '"><span class="node-kind">' + esc(kindLabel(node.kind)) + '</span><span class="node-label">' + esc(displayLabel(node)) + '</span>' + badges + '<span class="node-file">' + esc(file) + '</span></button></div>' + (isExpanded && childHtml ? '<div class="tree-children">' + childHtml + '</div>' : '') + '</div>';
616
+ }
617
+ function renderFiltered(ids) {
618
+ return [...ids].map(function (id) { return nodes.get(id); }).filter(function (node) { return node && node.id !== state.routeId && (state.filter === 'all' || node.kind === state.filter) && searchMatches(node); }).sort(function (a, b) { return a.kind.localeCompare(b.kind) || a.label.localeCompare(b.label); }).map(function (node) { return renderNode(node.id, 1, new Set()); }).join('');
619
+ }
620
+ function graphColumn(node) {
621
+ if (node.kind === 'route') return 0;
622
+ if (node.kind === 'component' || node.kind === 'route-hook') return 1;
623
+ if (node.kind === 'service') return state.graphServiceDependencyTargets.has(node.id) ? 3 : 2;
624
+ return 4;
625
+ }
626
+ function graphColumnTitle(column) {
627
+ return ['Route', 'Composants et hooks', 'Services du composant', 'Services utilisés par ces services', 'Détails et interactions'][column];
628
+ }
629
+ function graphCard(node) {
630
+ const file = relativePath(node.filePath) + (node.line ? ':' + node.line : '');
631
+ const selected = state.selectedId === node.id ? ' selected' : '';
632
+ const badges = node.kind === 'service' ? serviceBadge(node) : '';
633
+ return '<button class="graph-card kind-' + esc(node.kind) + selected + '" data-graph-node="' + esc(node.id) + '" title="' + esc(file) + '"><span class="card-topline"><span class="node-kind">' + esc(kindLabel(node.kind)) + '</span>' + badges + '</span><span class="node-label">' + esc(displayLabel(node)) + '</span><span class="node-file">' + esc(file) + '</span></button>';
634
+ }
635
+ function internalChildren(ownerId, ids) {
636
+ return (outgoing.get(ownerId) || []).filter(function (edge) {
637
+ return edge.kind === 'contains' && ids.has(edge.to) && nodes.has(edge.to);
638
+ }).map(function (edge) { return edge.to; }).filter(function (id, index, all) { return all.indexOf(id) === index; });
639
+ }
640
+ function dependencyChildren(ownerId, ids) {
641
+ return (outgoing.get(ownerId) || []).filter(function (edge) {
642
+ return (edge.kind === 'depends-on' || edge.kind === 'calls' || edge.kind === 'provides') && ids.has(edge.to) && nodes.has(edge.to);
643
+ }).map(function (edge) { return { id: edge.to, kind: edge.kind }; }).filter(function (item, index, all) {
644
+ return all.findIndex(function (other) { return other.id === item.id && other.kind === item.kind; }) === index;
645
+ });
646
+ }
647
+ function renderInternalNode(nodeId, ids, trail) {
648
+ if (trail.has(nodeId)) return '';
649
+ const node = nodes.get(nodeId);
650
+ if (!node) return '';
651
+ const children = internalChildren(nodeId, ids);
652
+ const dependencies = dependencyChildren(nodeId, ids);
653
+ const childHtml = children.map(function (childId) { return renderInternalNode(childId, ids, new Set([...trail, nodeId])); }).join('');
654
+ const dependencyHtml = dependencies.map(function (item) {
655
+ const target = nodes.get(item.id);
656
+ return target ? '<div class="internal-node"><span class="internal-relation">' + (item.kind === 'calls' ? 'appelle' : item.kind === 'provides' ? 'provide' : 'dépend de') + '</span>' + graphCard(target) + '</div>' : '';
657
+ }).join('');
658
+ return '<div class="internal-node"><span class="internal-relation">contient</span>' + graphCard(node) + childHtml + dependencyHtml + '</div>';
659
+ }
660
+ function componentChildrenForUsage(ownerId, ids, usage) {
661
+ return (outgoing.get(ownerId) || []).filter(function (edge) {
662
+ const edgeUsage = edge.details && edge.details.usage;
663
+ const matchesUsage = !edgeUsage || String(edgeUsage).split('+').indexOf(usage) >= 0;
664
+ const isInternal = edge.kind === 'contains' || ((edge.kind === 'uses-property' || edge.kind === 'calls') && matchesUsage);
665
+ return isInternal && ids.has(edge.to) && nodes.has(edge.to);
666
+ }).map(function (edge) { return edge.to; }).filter(function (id, index, all) { return all.indexOf(id) === index; });
667
+ }
668
+ function renderComponentPart(owner, ids, usage, title) {
669
+ const children = componentChildrenForUsage(owner.id, ids, usage);
670
+ const content = children.length
671
+ ? children.map(function (childId) { return renderInternalNode(childId, ids, new Set([owner.id])); }).join('')
672
+ : '<span class="component-empty">Aucun élément interne détecté</span>';
673
+ return '<section class="component-part"><div class="component-part-title">' + title + '</div><div class="component-anchor" data-graph-anchor="' + esc(owner.id + '::' + usage) + '">' + title + '</div>' + content + '</section>';
674
+ }
675
+ function renderComponentBlock(node, ids) {
676
+ return '<div class="graph-block">' + graphCard(node) + '<div class="component-parts">' + renderComponentPart(node, ids, 'setup', 'Setup') + renderComponentPart(node, ids, 'template', 'Template') + '</div></div>';
677
+ }
678
+ function renderGraphBlock(node, ids) {
679
+ if (node.kind === 'component') return renderComponentBlock(node, ids);
680
+ const children = internalChildren(node.id, ids);
681
+ if (!children.length) return graphCard(node);
682
+ return '<div class="graph-block">' + graphCard(node) + '<div class="graph-internals"><div class="internal-title">Contenu interne · ' + children.length + '</div>' + children.map(function (childId) { return renderInternalNode(childId, ids, new Set([node.id])); }).join('') + '</div></div>';
683
+ }
684
+ function graphEndpointKey(nodeId, edge, source) {
685
+ const node = nodes.get(nodeId);
686
+ if (!source || !node || node.kind !== 'component') return nodeId;
687
+ const usage = edge.details && edge.details.usage;
688
+ return nodeId + '::' + (usage && String(usage).indexOf('template') >= 0 ? 'template' : 'setup');
689
+ }
690
+ function renderGraph(ids) {
691
+ const visibleNodes = [...ids].map(function (id) { return nodes.get(id); }).filter(function (node) {
692
+ return node && (node.id === state.routeId || ((state.filter === 'all' || node.kind === state.filter) && searchMatches(node)));
693
+ });
694
+ state.graphIds = new Set(visibleNodes.map(function (node) { return node.id; }));
695
+ state.graphServiceDependencyTargets = new Set();
696
+ (GRAPH.edges || []).filter(function (edge) {
697
+ return edge.kind === 'depends-on' && state.graphIds.has(edge.from) && state.graphIds.has(edge.to);
698
+ }).forEach(function (edge) {
699
+ const target = nodes.get(edge.to);
700
+ const source = nodes.get(edge.from);
701
+ if (target && target.kind === 'service' && source && (source.kind === 'service' || source.kind === 'primitive')) {
702
+ state.graphServiceDependencyTargets.add(target.id);
703
+ }
704
+ });
705
+ state.graphInternalOwner = new Map();
706
+ visibleNodes.filter(function (node) { return node.kind === 'route' || node.kind === 'component' || node.kind === 'service'; }).forEach(function (owner) {
707
+ const queue = internalChildren(owner.id, state.graphIds);
708
+ const visited = new Set();
709
+ while (queue.length) {
710
+ const childId = queue.shift();
711
+ if (visited.has(childId)) continue;
712
+ visited.add(childId);
713
+ state.graphInternalOwner.set(childId, owner.id);
714
+ internalChildren(childId, state.graphIds).forEach(function (nestedId) { queue.push(nestedId); });
715
+ }
716
+ });
717
+ const columns = [[], [], [], [], []];
718
+ visibleNodes.filter(function (node) { return !state.graphInternalOwner.has(node.id); }).forEach(function (node) { columns[graphColumn(node)].push(node); });
719
+ columns.forEach(function (column) { column.sort(function (a, b) { return a.kind.localeCompare(b.kind) || a.label.localeCompare(b.label); }); });
720
+ const columnHtml = columns.map(function (column, index) {
721
+ return '<section class="graph-column"><div class="graph-column-title">' + graphColumnTitle(index) + ' <span>(' + column.length + ')</span></div>' + (column.length ? column.map(function (node) { return renderGraphBlock(node, state.graphIds); }).join('') : '<div class="empty">Aucun nœud</div>') + '</section>';
722
+ }).join('');
723
+ return '<div class="graph-scroll"><div class="graph-canvas" id="graph-canvas"><svg class="graph-edges" aria-hidden="true"><defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9aa9bf"></path></marker><marker id="arrow-depends" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#00a884"></path></marker><marker id="arrow-primitive-member" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#a7d8ca"></path></marker></defs></svg>' + columnHtml + '</div></div>';
724
+ }
725
+ function drawGraphEdges() {
726
+ const canvas = document.getElementById('graph-canvas');
727
+ if (!canvas) return;
728
+ const svg = canvas.querySelector('.graph-edges');
729
+ const bounds = canvas.getBoundingClientRect();
730
+ const elements = new Map([...canvas.querySelectorAll('[data-graph-node], [data-graph-anchor]')].map(function (element) { return [element.getAttribute('data-graph-node') || element.getAttribute('data-graph-anchor'), element]; }));
731
+ svg.setAttribute('width', canvas.scrollWidth);
732
+ svg.setAttribute('height', canvas.scrollHeight);
733
+ svg.setAttribute('viewBox', '0 0 ' + canvas.scrollWidth + ' ' + canvas.scrollHeight);
734
+ svg.querySelectorAll('path.graph-edge').forEach(function (element) { element.remove(); });
735
+ (GRAPH.edges || []).filter(function (edge) { return state.graphIds.has(edge.from) && state.graphIds.has(edge.to); }).forEach(function (edge) {
736
+ const fromOwner = state.graphInternalOwner.get(edge.from);
737
+ const toOwner = state.graphInternalOwner.get(edge.to);
738
+ if (fromOwner && fromOwner === toOwner && edge.kind === 'contains') return;
739
+ const from = elements.get(graphEndpointKey(edge.from, edge, true));
740
+ const to = elements.get(graphEndpointKey(edge.to, edge, false));
741
+ if (!from || !to) return;
742
+ const fromBox = from.getBoundingClientRect();
743
+ const toBox = to.getBoundingClientRect();
744
+ const fromX = fromBox.left - bounds.left;
745
+ const fromY = fromBox.top - bounds.top;
746
+ const toX = toBox.left - bounds.left;
747
+ const toY = toBox.top - bounds.top;
748
+ let startX;
749
+ let startY;
750
+ let endX;
751
+ let endY;
752
+ let pathData;
753
+ if (Math.abs(toX - fromX) < 35) {
754
+ startX = fromX + fromBox.width / 2;
755
+ startY = fromY + fromBox.height;
756
+ endX = toX + toBox.width / 2;
757
+ endY = toY;
758
+ const bend = Math.max(22, (endY - startY) / 2);
759
+ pathData = 'M ' + startX + ' ' + startY + ' C ' + startX + ' ' + (startY + bend) + ', ' + endX + ' ' + (endY - bend) + ', ' + endX + ' ' + endY;
760
+ } else if (toX > fromX) {
761
+ startX = fromX + fromBox.width;
762
+ startY = fromY + fromBox.height / 2;
763
+ endX = toX;
764
+ endY = toY + toBox.height / 2;
765
+ const bend = Math.max(32, (endX - startX) / 2);
766
+ pathData = 'M ' + startX + ' ' + startY + ' C ' + (startX + bend) + ' ' + startY + ', ' + (endX - bend) + ' ' + endY + ', ' + endX + ' ' + endY;
767
+ } else {
768
+ startX = fromX;
769
+ startY = fromY + fromBox.height / 2;
770
+ endX = toX + toBox.width;
771
+ endY = toY + toBox.height / 2;
772
+ const bend = Math.max(32, (startX - endX) / 2);
773
+ pathData = 'M ' + startX + ' ' + startY + ' C ' + (startX - bend) + ' ' + startY + ', ' + (endX + bend) + ' ' + endY + ', ' + endX + ' ' + endY;
774
+ }
775
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
776
+ const sourceNode = nodes.get(edge.from);
777
+ const targetNode = nodes.get(edge.to);
778
+ const primitiveMemberEdge =
779
+ edge.kind === 'depends-on' &&
780
+ sourceNode?.kind === 'primitive' &&
781
+ targetNode?.kind === 'property';
782
+ const propertyUsageClass =
783
+ edge.kind === 'uses-property'
784
+ ? edge.details?.usage === 'template'
785
+ ? ' edge-template'
786
+ : edge.details?.usage === 'setup'
787
+ ? ' edge-setup'
788
+ : edge.details?.usage === 'setup+template'
789
+ ? ' edge-both'
790
+ : ''
791
+ : '';
792
+ path.setAttribute(
793
+ 'class',
794
+ 'graph-edge edge-' + edge.kind +
795
+ (primitiveMemberEdge ? ' edge-primitive-member' : '') +
796
+ propertyUsageClass,
797
+ );
798
+ path.setAttribute('d', pathData);
799
+ path.setAttribute(
800
+ 'marker-end',
801
+ primitiveMemberEdge
802
+ ? 'url(#arrow-primitive-member)'
803
+ : edge.kind === 'depends-on'
804
+ ? 'url(#arrow-depends)'
805
+ : 'url(#arrow)',
806
+ );
807
+ path.setAttribute('data-kind', edge.kind);
808
+ svg.appendChild(path);
809
+ });
810
+ }
811
+ function renderTree() {
812
+ const route = nodes.get(state.routeId);
813
+ const tree = document.getElementById('tree');
814
+ if (!route) { tree.innerHTML = '<div class="empty">Aucune route disponible dans ce projet.</div>'; return; }
815
+ const ids = routeReachability.get(route.id) || new Set();
816
+ document.getElementById('route-title').textContent = route.label;
817
+ document.getElementById('route-subtitle').textContent = ids.size + ' nœuds accessibles · ' + (serviceRoutesForRoute(route.id).length) + ' services · les badges orange signalent les dépendances partagées.';
818
+ tree.innerHTML = renderGraph(ids) || '<div class="empty">Aucune dépendance détectée.</div>';
819
+ tree.querySelectorAll('[data-graph-node]').forEach(function (button) { button.addEventListener('click', function () { selectNode(button.getAttribute('data-graph-node')); }); });
820
+ requestAnimationFrame(drawGraphEdges);
821
+ }
822
+ function serviceRoutesForRoute(routeId) {
823
+ const ids = routeReachability.get(routeId) || new Set();
824
+ return services.filter(function (service) { return ids.has(service.id); });
825
+ }
826
+ function relationList(title, edges, targetKey) {
827
+ if (!edges.length) return '';
828
+ return '<div class="detail-section"><h3>' + title + '</h3><ul class="detail-list">' + edges.map(function (edge) { const target = nodes.get(edge[targetKey]); const relation = edge.kind === 'depends-on' ? '' : edge.kind; return target ? '<li>' + (relation ? '<span class="relation-kind">' + esc(relation) + '</span>' : '') + '<button data-detail-node="' + esc(target.id) + '">' + esc(target.label) + '</button></li>' : ''; }).join('') + '</ul></div>';
829
+ }
830
+ function renderDetails() {
831
+ const node = nodes.get(state.selectedId);
832
+ const container = document.getElementById('details');
833
+ if (!node) { container.innerHTML = '<div class="detail-empty">Cliquez sur un nœud pour voir ses détails.</div>'; return; }
834
+ const outgoingEdges = outgoing.get(node.id) || [];
835
+ const incomingEdges = incoming.get(node.id) || [];
836
+ const location = relativePath(node.filePath) + (node.line ? ':' + node.line : '');
837
+ const users = node.kind === 'service' ? (serviceRoutes.get(node.id) || []) : [];
838
+ let html = '<div class="detail-head"><span class="badge">' + esc(kindLabel(node.kind)) + '</span><h2>' + esc(node.label) + '</h2><p>' + esc(location || 'Emplacement inconnu') + '</p></div>';
839
+ if (users.length > 1) {
840
+ html += '<div class="callout"><strong>Service partagé</strong><br>Utilisé par ' + users.length + ' routes. Cette dépendance dépasse la route sélectionnée.</div><div class="detail-section"><h3>Routes utilisatrices</h3><ul class="detail-list">' + users.map(function (route) { return '<li><span class="relation-kind">route</span><button data-route-detail="' + esc(route.id) + '">' + esc(route.label) + '</button></li>'; }).join('') + '</ul></div>';
841
+ } else if (node.kind === 'service') {
842
+ html += '<div class="detail-section"><div class="badge">spécifique à la route affichée</div></div>';
843
+ }
844
+ const details = Object.entries(node.details || {});
845
+ if (details.length) html += '<div class="detail-section"><h3>Métadonnées</h3><table class="meta-table">' + details.map(function (entry) { return '<tr><th>' + esc(entry[0]) + '</th><td>' + esc(Array.isArray(entry[1]) ? entry[1].join(', ') : typeof entry[1] === 'object' ? JSON.stringify(entry[1]) : entry[1]) + '</td></tr>'; }).join('') + '</table></div>';
846
+ html += relationList('Dépend de / contient', outgoingEdges, 'to');
847
+ html += relationList('Utilisé par', incomingEdges, 'from');
848
+ html += '<div class="detail-section"><h3>Preuve</h3><p class="badge">' + (outgoingEdges.concat(incomingEdges).some(function (edge) { return edge.evidence === 'type'; }) ? 'TypeScript + AST' : 'AST') + '</p></div>';
849
+ container.innerHTML = html;
850
+ container.querySelectorAll('[data-detail-node]').forEach(function (button) { button.addEventListener('click', function () { selectNode(button.getAttribute('data-detail-node')); }); });
851
+ container.querySelectorAll('[data-route-detail]').forEach(function (button) { button.addEventListener('click', function () { selectRoute(button.getAttribute('data-route-detail')); }); });
852
+ }
853
+ function renderStats() {
854
+ const sharedCount = services.filter(function (service) { return (serviceRoutes.get(service.id) || []).length > 1; }).length;
855
+ document.getElementById('stats').innerHTML = '<span><strong>' + routes.length + '</strong>routes</span><span><strong>' + GRAPH.nodes.length + '</strong>nœuds</span><span><strong>' + sharedCount + '</strong>services partagés</span>';
856
+ }
857
+ document.getElementById('search').addEventListener('input', function (event) { state.search = event.target.value.trim(); renderRoutes(); renderTree(); });
858
+ renderStats();
859
+ renderFilters();
860
+ if (state.routeId) selectRoute(state.routeId); else { renderRoutes(); renderTree(); renderDetails(); }
861
+ </script>
862
+ </body>
863
+ </html>
864
+ `;
865
+ }
866
+ function collectServices(builder, sourceFiles) {
867
+ for (const sourceFile of sourceFiles) {
868
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
869
+ if (call.getExpression().getText() !== 'craftService')
870
+ continue;
871
+ const config = call
872
+ .getArguments()[0]
873
+ ?.asKind(SyntaxKind.ObjectLiteralExpression);
874
+ const name = getStringProperty(config, 'name') ??
875
+ inferNameFromServiceDeclaration(call);
876
+ if (!name)
877
+ continue;
878
+ const node = addNode(builder, {
879
+ id: `service:${sourceFile.getFilePath()}:${name}`,
880
+ kind: 'service',
881
+ label: name,
882
+ filePath: sourceFile.getFilePath(),
883
+ line: call.getStartLineNumber(),
884
+ details: {
885
+ scope: getStringProperty(config, 'scope'),
886
+ appStart: getBooleanProperty(config, 'appStart') === true,
887
+ browserBoundary: getBooleanProperty(config, 'browserBoundary') === true,
888
+ outputProperties: [],
889
+ },
890
+ });
891
+ const service = {
892
+ node,
893
+ helpers: new Set(),
894
+ call,
895
+ outputPropertyNames: new Set(),
896
+ };
897
+ const declaration = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
898
+ if (declaration) {
899
+ addBindingNames(declaration.getNameNode(), service.helpers);
900
+ }
901
+ for (const helper of service.helpers) {
902
+ const binding = findBindingElement(declaration?.getNameNode(), helper);
903
+ const key = symbolKey(binding?.getNameNode().getSymbol());
904
+ if (key)
905
+ builder.serviceByHelperKey.set(key, service);
906
+ const services = builder.servicesByHelperName.get(helper) ?? [];
907
+ if (!services.includes(service))
908
+ services.push(service);
909
+ builder.servicesByHelperName.set(helper, services);
910
+ }
911
+ service.outputType = [...service.helpers]
912
+ .map((helper) => call.getType().getProperty(helper)?.getTypeAtLocation(call))
913
+ .map((type) => getServiceHelperOutputType(type))
914
+ .find((type) => type !== undefined);
915
+ for (const property of service.outputType?.getProperties() ?? []) {
916
+ service.outputPropertyNames.add(property.getName());
917
+ }
918
+ if (node.details) {
919
+ node.details['outputProperties'] = [...service.outputPropertyNames];
920
+ node.details['helpers'] = [...service.helpers];
921
+ }
922
+ builder.services.push(service);
923
+ }
924
+ }
925
+ for (const service of builder.services) {
926
+ collectProvides(builder, service.node.id, service.call.getArguments()[0]);
927
+ }
928
+ }
929
+ function collectSources(builder, sourceFiles) {
930
+ for (const sourceFile of sourceFiles) {
931
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
932
+ const creator = call.getExpression().getText();
933
+ if (!SOURCE_CREATORS.has(creator))
934
+ continue;
935
+ const name = getStringArgument(call, 0) ?? creator;
936
+ const variableNames = new Set();
937
+ const declaration = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
938
+ if (declaration)
939
+ addBindingNames(declaration.getNameNode(), variableNames);
940
+ const node = addNode(builder, {
941
+ id: `source:${sourceFile.getFilePath()}:${name}:${call.getStartLineNumber()}`,
942
+ kind: 'source',
943
+ label: `${name} (${creator})`,
944
+ filePath: sourceFile.getFilePath(),
945
+ line: call.getStartLineNumber(),
946
+ details: { creator },
947
+ });
948
+ builder.sources.push({ node, variableNames, call });
949
+ }
950
+ }
951
+ }
952
+ function collectCraftUniques(builder, sourceFiles) {
953
+ for (const sourceFile of sourceFiles) {
954
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
955
+ if (call.getExpression().getText() !== 'craftUnique')
956
+ continue;
957
+ addCraftUniqueUsage(builder, call);
958
+ }
959
+ }
960
+ }
961
+ function collectComponents(builder, sourceFiles) {
962
+ for (const sourceFile of sourceFiles) {
963
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
964
+ if (call.getExpression().getText() !== 'craftComponent')
965
+ continue;
966
+ const explicitLabel = getStringArgument(call, 0);
967
+ const label = explicitLabel ?? `AnonymousComponent@${call.getStartLineNumber()}`;
968
+ const component = {
969
+ node: addNode(builder, {
970
+ id: `component:${sourceFile.getFilePath()}:${label}`,
971
+ kind: 'component',
972
+ label,
973
+ filePath: sourceFile.getFilePath(),
974
+ line: call.getStartLineNumber(),
975
+ ...(explicitLabel ? {} : { details: { anonymous: true } }),
976
+ }),
977
+ call,
978
+ bindings: new Map(),
979
+ };
980
+ builder.components.push(component);
981
+ collectProvides(builder, component.node.id, call.getArguments()[1]);
982
+ const declaration = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
983
+ if (declaration) {
984
+ for (const name of getBindingNames(declaration.getNameNode())) {
985
+ builder.componentByVariable.set(name, component);
986
+ const nameNode = findBindingNameNode(declaration.getNameNode(), name);
987
+ const key = symbolKey(nameNode?.getSymbol());
988
+ if (key)
989
+ builder.componentByVariableKey.set(key, component);
990
+ const components = builder.componentsByVariableName.get(name) ?? [];
991
+ if (!components.includes(component))
992
+ components.push(component);
993
+ builder.componentsByVariableName.set(name, components);
994
+ }
995
+ }
996
+ }
997
+ }
998
+ }
999
+ function collectRoutes(builder, sourceFiles) {
1000
+ for (const sourceFile of sourceFiles) {
1001
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1002
+ if (call.getExpression().getText() !== 'craftRoutes')
1003
+ continue;
1004
+ const collectionName = getStringArgument(call, 0) ?? sourceFile.getBaseNameWithoutExtension();
1005
+ const routesName = getCraftRoutesBindingName(call) ?? collectionName;
1006
+ const routes = call
1007
+ .getArguments()[1]
1008
+ ?.asKind(SyntaxKind.ArrayLiteralExpression);
1009
+ if (!routes)
1010
+ continue;
1011
+ const routeInfos = [];
1012
+ for (const element of routes.getElements()) {
1013
+ const routeCall = element.asKind(SyntaxKind.CallExpression);
1014
+ const object = element.asKind(SyntaxKind.ObjectLiteralExpression) ??
1015
+ (routeCall?.getExpression().getText() === 'craftRoute'
1016
+ ? routeCall
1017
+ .getArguments()[1]
1018
+ ?.asKind(SyntaxKind.ObjectLiteralExpression)
1019
+ : undefined);
1020
+ if (!object)
1021
+ continue;
1022
+ const path = getStringProperty(object, 'path') ??
1023
+ (routeCall ? getStringArgument(routeCall, 0) : undefined) ??
1024
+ '<dynamic>';
1025
+ const label = `${collectionName}:${path}`;
1026
+ const route = {
1027
+ node: addNode(builder, {
1028
+ id: `route:${sourceFile.getFilePath()}:${label}`,
1029
+ kind: 'route',
1030
+ label,
1031
+ filePath: sourceFile.getFilePath(),
1032
+ line: object.getStartLineNumber(),
1033
+ details: {
1034
+ collection: collectionName,
1035
+ path,
1036
+ routesName,
1037
+ hasComponent: routeHasTargetComponent(object),
1038
+ hasPendingComponent: Boolean(object.getProperty('pendingComponent')),
1039
+ hasErrorComponent: Boolean(object.getProperty('errorComponent')),
1040
+ },
1041
+ }),
1042
+ sourceFile,
1043
+ object,
1044
+ routesName,
1045
+ collectionName,
1046
+ };
1047
+ routeInfos.push(route);
1048
+ analyzeRouteObject(builder, route);
1049
+ }
1050
+ const existing = builder.routeFiles.get(sourceFile.getFilePath()) ?? [];
1051
+ builder.routeFiles.set(sourceFile.getFilePath(), [
1052
+ ...existing,
1053
+ ...routeInfos,
1054
+ ]);
1055
+ }
1056
+ }
1057
+ }
1058
+ function collectAppConfigs(builder, sourceFiles) {
1059
+ for (const sourceFile of sourceFiles) {
1060
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1061
+ if (call.getExpression().getText() !== 'craftAppConfig')
1062
+ continue;
1063
+ const object = call
1064
+ .getArguments()[0]
1065
+ ?.asKind(SyntaxKind.ObjectLiteralExpression);
1066
+ if (!object)
1067
+ continue;
1068
+ const declaration = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1069
+ const nameNode = declaration?.getNameNode();
1070
+ const label = nameNode && Node.isIdentifier(nameNode)
1071
+ ? nameNode.getText()
1072
+ : 'appConfig';
1073
+ const calls = object.getDescendantsOfKind(SyntaxKind.CallExpression);
1074
+ const globalErrorCall = calls.find((item) => item.getExpression().getText() ===
1075
+ 'provideCraftGlobalErrorComponent' ||
1076
+ item.getExpression().getText() === 'withErrorComponent');
1077
+ const routeLoadErrorCall = calls.find((item) => item.getExpression().getText() ===
1078
+ 'provideCraftRouteLoadErrorComponent' ||
1079
+ item.getExpression().getText() === 'withRouteLoadError');
1080
+ builder.appConfigs.push({
1081
+ node: addNode(builder, {
1082
+ id: `app-config:${sourceFile.getFilePath()}:${label}`,
1083
+ kind: 'app-config',
1084
+ label,
1085
+ filePath: sourceFile.getFilePath(),
1086
+ line: object.getStartLineNumber(),
1087
+ details: {
1088
+ hasGlobalError: Boolean(globalErrorCall),
1089
+ hasRouteLoadError: Boolean(routeLoadErrorCall),
1090
+ globalErrorComponent: appConfigComponentName(globalErrorCall),
1091
+ routeLoadErrorComponent: appConfigComponentName(routeLoadErrorCall),
1092
+ },
1093
+ }),
1094
+ sourceFile,
1095
+ object,
1096
+ });
1097
+ }
1098
+ }
1099
+ }
1100
+ function appConfigComponentName(call) {
1101
+ if (!call)
1102
+ return undefined;
1103
+ const first = call.getArguments()[0];
1104
+ if (!first)
1105
+ return undefined;
1106
+ if (Node.isIdentifier(first))
1107
+ return first.getText();
1108
+ const object = first.asKind(SyntaxKind.ObjectLiteralExpression);
1109
+ const component = object
1110
+ ?.getProperty('component')
1111
+ ?.asKind(SyntaxKind.PropertyAssignment)
1112
+ ?.getInitializer();
1113
+ return component && Node.isIdentifier(component)
1114
+ ? component.getText()
1115
+ : undefined;
1116
+ }
1117
+ function getCraftRoutesBindingName(call) {
1118
+ const declaration = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1119
+ const nameNode = declaration?.getNameNode();
1120
+ if (!nameNode)
1121
+ return undefined;
1122
+ if (Node.isIdentifier(nameNode))
1123
+ return nameNode.getText();
1124
+ if (!Node.isObjectBindingPattern(nameNode))
1125
+ return undefined;
1126
+ const collection = call.getArguments()[0];
1127
+ const expected = Node.isStringLiteral(collection)
1128
+ ? `${uncapitalize(toPascalCase(collection.getLiteralValue()))}Routes`
1129
+ : undefined;
1130
+ const routeBindings = nameNode
1131
+ .getElements()
1132
+ .filter((element) => (element.getPropertyNameNode()?.getText() ?? element.getName()).endsWith('Routes'));
1133
+ const match = routeBindings.find((element) => expected !== undefined &&
1134
+ (element.getPropertyNameNode()?.getText() ?? element.getName()) ===
1135
+ expected);
1136
+ return (match?.getName() ??
1137
+ (routeBindings.length === 1 ? routeBindings[0].getName() : undefined));
1138
+ }
1139
+ function toPascalCase(value) {
1140
+ return (value
1141
+ .split(/[^A-Za-z0-9]+/)
1142
+ .filter(Boolean)
1143
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1144
+ .join('') || 'Routes');
1145
+ }
1146
+ function uncapitalize(value) {
1147
+ return value.charAt(0).toLowerCase() + value.slice(1);
1148
+ }
1149
+ function routeHasTargetComponent(object) {
1150
+ if (object
1151
+ .getProperties()
1152
+ .some((property) => Node.isPropertyAssignment(property) &&
1153
+ ['component', 'componentDeps', 'loadComponent'].includes(property.getName()))) {
1154
+ return true;
1155
+ }
1156
+ return object
1157
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
1158
+ .some((call) => call.getExpression().getText() === 'loadCraftComponent');
1159
+ }
1160
+ function collectRouteChecks(builder) {
1161
+ const files = new Map();
1162
+ for (const routes of builder.routeFiles.values()) {
1163
+ const sourceFile = routes[0]?.sourceFile;
1164
+ if (!sourceFile)
1165
+ continue;
1166
+ files.set(sourceFile.getFilePath(), { sourceFile, routes });
1167
+ }
1168
+ for (const appConfig of builder.appConfigs) {
1169
+ const path = appConfig.sourceFile.getFilePath();
1170
+ const existing = files.get(path);
1171
+ if (existing) {
1172
+ existing.appConfig = appConfig;
1173
+ continue;
1174
+ }
1175
+ files.set(path, {
1176
+ sourceFile: appConfig.sourceFile,
1177
+ routes: [],
1178
+ appConfig,
1179
+ });
1180
+ }
1181
+ for (const { sourceFile, routes, appConfig } of files.values()) {
1182
+ const aliases = new Map(sourceFile.getTypeAliases().map((alias) => [alias.getName(), alias]));
1183
+ for (const alias of sourceFile.getTypeAliases()) {
1184
+ const typeNode = alias.getTypeNode();
1185
+ if (!typeNode || !Node.isTypeReference(typeNode))
1186
+ continue;
1187
+ const typeName = typeNode.getTypeName().getText();
1188
+ if (typeName === 'CanRun') {
1189
+ const canRun = addRouteCheckNode(builder, sourceFile, alias.getName(), 'CanRun', alias.getStartLineNumber());
1190
+ const innerNode = typeNode.getTypeArguments()[0];
1191
+ if (!innerNode)
1192
+ continue;
1193
+ const inner = resolveRouteCheck(innerNode, aliases, new Set());
1194
+ if (!inner || inner.mechanism === 'CanRun')
1195
+ continue;
1196
+ const innerRef = Node.isTypeReference(innerNode)
1197
+ ? innerNode
1198
+ : undefined;
1199
+ const innerName = innerRef?.getTypeName().getText();
1200
+ const mapperName = innerName &&
1201
+ aliases.has(innerName) &&
1202
+ (innerRef?.getTypeArguments().length ?? 0) === 0
1203
+ ? innerName
1204
+ : `${alias.getName()}:${inner.mechanism}`;
1205
+ const mapper = addRouteCheckNode(builder, sourceFile, mapperName, inner.mechanism, alias.getStartLineNumber());
1206
+ addEdge(builder, canRun.id, mapper.id, 'contains', 'ast');
1207
+ linkMapperToRoutes(builder, mapper.id, inner, routes);
1208
+ linkMapperToAppConfig(builder, mapper.id, inner, appConfig);
1209
+ continue;
1210
+ }
1211
+ if (alias.getTypeParameters().length > 0)
1212
+ continue;
1213
+ const resolved = resolveRouteCheck(typeNode, aliases, new Set());
1214
+ if (!resolved || resolved.mechanism === 'CanRun')
1215
+ continue;
1216
+ const mapper = addRouteCheckNode(builder, sourceFile, alias.getName(), resolved.mechanism, alias.getStartLineNumber());
1217
+ linkMapperToRoutes(builder, mapper.id, resolved, routes);
1218
+ linkMapperToAppConfig(builder, mapper.id, resolved, appConfig);
1219
+ }
1220
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1221
+ if (call.getExpression().getText() !== 'assertExhaustiveRouteExceptions') {
1222
+ continue;
1223
+ }
1224
+ const targetName = call.getArguments()[0]?.getText();
1225
+ const assertNode = addRouteCheckNode(builder, sourceFile, `assertExhaustiveRouteExceptions:${targetName ?? call.getStartLineNumber()}`, 'assertExhaustiveRouteExceptions', call.getStartLineNumber());
1226
+ for (const route of routes) {
1227
+ if (!targetName || route.routesName === targetName) {
1228
+ addEdge(builder, assertNode.id, route.node.id, 'checks', 'ast', {
1229
+ target: 'collection',
1230
+ });
1231
+ }
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+ function addRouteCheckNode(builder, sourceFile, name, mechanism, line) {
1237
+ return addNode(builder, {
1238
+ id: `route-check:${sourceFile.getFilePath()}:${name}`,
1239
+ kind: 'route-check',
1240
+ label: `${mechanism} ${name}`,
1241
+ filePath: sourceFile.getFilePath(),
1242
+ line,
1243
+ details: { mechanism, name },
1244
+ });
1245
+ }
1246
+ function resolveRouteCheck(typeNode, aliases, visited) {
1247
+ if (!Node.isTypeReference(typeNode))
1248
+ return undefined;
1249
+ const name = typeNode.getTypeName().getText();
1250
+ const canonical = [
1251
+ 'CanRun',
1252
+ 'ValidateCascadeRoutesFile',
1253
+ 'RouteCheckedDI',
1254
+ 'RouteExceptionComponentCheckedDI',
1255
+ ];
1256
+ if (canonical.includes(name)) {
1257
+ return { mechanism: name, hintNodes: [typeNode] };
1258
+ }
1259
+ if (visited.has(name))
1260
+ return undefined;
1261
+ visited.add(name);
1262
+ const alias = aliases.get(name);
1263
+ const body = alias?.getTypeNode();
1264
+ if (!body)
1265
+ return undefined;
1266
+ const inner = resolveRouteCheck(body, aliases, visited);
1267
+ if (!inner)
1268
+ return undefined;
1269
+ return {
1270
+ mechanism: inner.mechanism,
1271
+ hintNodes: [typeNode, ...inner.hintNodes],
1272
+ };
1273
+ }
1274
+ function collectTypeCheckHints(node) {
1275
+ const text = node.getText();
1276
+ return {
1277
+ imports: [...text.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)].map((match) => match[1] ?? ''),
1278
+ strings: [
1279
+ ...(Node.isStringLiteral(node) ? [node.getLiteralValue()] : []),
1280
+ ...node
1281
+ .getDescendantsOfKind(SyntaxKind.StringLiteral)
1282
+ .map((literal) => literal.getLiteralValue()),
1283
+ ],
1284
+ typeofNames: [...text.matchAll(/\btypeof\s+([A-Za-z_$][\w$]*)/g)].map((match) => match[1] ?? ''),
1285
+ };
1286
+ }
1287
+ function mergeTypeCheckHints(nodes) {
1288
+ const merged = { imports: [], strings: [], typeofNames: [] };
1289
+ for (const node of nodes) {
1290
+ const hints = collectTypeCheckHints(node);
1291
+ merged.imports.push(...hints.imports);
1292
+ merged.strings.push(...hints.strings);
1293
+ merged.typeofNames.push(...hints.typeofNames);
1294
+ }
1295
+ return merged;
1296
+ }
1297
+ function linkMapperToRoutes(builder, mapperId, resolved, routes) {
1298
+ const hints = mergeTypeCheckHints(resolved.hintNodes);
1299
+ const pending = hints.strings.some((value) => /pending/i.test(value));
1300
+ const error = hints.strings.some((value) => /error component|exception component/i.test(value));
1301
+ for (const route of routes) {
1302
+ if (!routeCheckCoversRoute(hints, route))
1303
+ continue;
1304
+ if (pending && route.node.details?.['hasPendingComponent']) {
1305
+ addEdge(builder, mapperId, route.node.id, 'checks', 'type', {
1306
+ target: 'pending',
1307
+ });
1308
+ continue;
1309
+ }
1310
+ if (error && route.node.details?.['hasErrorComponent']) {
1311
+ addEdge(builder, mapperId, route.node.id, 'checks', 'type', {
1312
+ target: 'error',
1313
+ });
1314
+ continue;
1315
+ }
1316
+ if (route.node.details?.['hasComponent']) {
1317
+ addEdge(builder, mapperId, route.node.id, 'checks', 'type', {
1318
+ target: 'component',
1319
+ });
1320
+ }
1321
+ }
1322
+ }
1323
+ function linkMapperToAppConfig(builder, mapperId, resolved, appConfig) {
1324
+ if (!appConfig)
1325
+ return;
1326
+ if (resolved.mechanism !== 'RouteExceptionComponentCheckedDI')
1327
+ return;
1328
+ const hints = mergeTypeCheckHints(resolved.hintNodes);
1329
+ const blob = [
1330
+ ...hints.strings,
1331
+ ...hints.typeofNames,
1332
+ ...resolved.hintNodes.map((node) => node.getText()),
1333
+ ].join(' ');
1334
+ const component = String(appConfig.node.details?.['globalErrorComponent'] ?? '');
1335
+ const loadComponent = String(appConfig.node.details?.['routeLoadErrorComponent'] ?? '');
1336
+ if (appConfig.node.details?.['hasGlobalError'] &&
1337
+ ((/global error/i.test(blob) && !/route load error/i.test(blob)) ||
1338
+ blobNamesComponent(blob, component))) {
1339
+ addEdge(builder, mapperId, appConfig.node.id, 'checks', 'type', {
1340
+ target: 'global-error',
1341
+ });
1342
+ }
1343
+ if (appConfig.node.details?.['hasRouteLoadError'] &&
1344
+ (/route load error/i.test(blob) || blobNamesComponent(blob, loadComponent))) {
1345
+ addEdge(builder, mapperId, appConfig.node.id, 'checks', 'type', {
1346
+ target: 'route-load-error',
1347
+ });
1348
+ }
1349
+ }
1350
+ function blobNamesComponent(blob, name) {
1351
+ if (!name)
1352
+ return false;
1353
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1354
+ return new RegExp(`\\b(?:typeof\\s+)?${escaped}\\b`).test(blob);
1355
+ }
1356
+ function routeCheckCoversRoute(hints, route) {
1357
+ if (hints.typeofNames.includes(route.routesName))
1358
+ return true;
1359
+ const routeImports = findDynamicImportSpecifiers(route.object);
1360
+ if (hints.imports.some((specifier) => routeImports.includes(specifier))) {
1361
+ return true;
1362
+ }
1363
+ const path = String(route.node.details?.['path'] ?? '');
1364
+ return hints.strings.some((value) => typeStringCoversPath(value, path));
1365
+ }
1366
+ function typeStringCoversPath(value, path) {
1367
+ if (path === '') {
1368
+ return /path:\s*['"]{2}/.test(value) || value === '';
1369
+ }
1370
+ if (value === path)
1371
+ return true;
1372
+ const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1373
+ if (new RegExp(`(?:path:\\s*)?['"]${escaped}['"]`).test(value))
1374
+ return true;
1375
+ return value.endsWith(`/${path}`) || value.endsWith(path);
1376
+ }
1377
+ function analyzeServiceBodies(builder) {
1378
+ for (const service of builder.services) {
1379
+ const factory = service.call.getArguments()[1];
1380
+ if (!factory)
1381
+ continue;
1382
+ for (const call of factory.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1383
+ const httpClientUsage = findCraftHttpClientUsage(call);
1384
+ if (httpClientUsage) {
1385
+ const ownerNode = ownerNodeForCall(builder, call, service.node.id);
1386
+ addHttpClientUsage(builder, ownerNode.id, httpClientUsage, {
1387
+ ...(resourceLoaderFactory(call) ? { resourceRole: 'loader' } : {}),
1388
+ });
1389
+ }
1390
+ addServerFunctionUsage(builder, call, service.node.id);
1391
+ const temporalUsage = findCraftTemporalUsage(call);
1392
+ if (temporalUsage) {
1393
+ const ownerNode = ownerNodeForCall(builder, call, service.node.id);
1394
+ addTemporalUsage(builder, ownerNode.id, temporalUsage);
1395
+ }
1396
+ const helper = findServiceForCall(builder, call);
1397
+ if (helper && helper !== service) {
1398
+ addEdge(builder, service.node.id, helper.node.id, 'depends-on', 'type');
1399
+ addServiceDependency(builder, service.node.id, helper, call);
1400
+ }
1401
+ if (isPrimitiveFactory(call)) {
1402
+ addOwnedPrimitive(builder, call, service.node.id);
1403
+ }
1404
+ }
1405
+ const bindings = collectReactiveBindings(builder, factory, service.node.id);
1406
+ analyzeReactiveDependencies(builder, factory, bindings, service.node.id);
1407
+ addSourceInteractions(builder, service.node.id, factory);
1408
+ }
1409
+ }
1410
+ function analyzeComponents(builder) {
1411
+ for (const component of builder.components) {
1412
+ const call = component.call;
1413
+ const setup = call.getArguments()[2];
1414
+ const template = call.getArguments()[3];
1415
+ for (const part of [setup, template]) {
1416
+ if (!part)
1417
+ continue;
1418
+ collectServiceBindingsFromReturns(component, part, builder);
1419
+ for (const nested of part.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1420
+ const httpClientUsage = findCraftHttpClientUsage(nested);
1421
+ if (httpClientUsage) {
1422
+ const ownerNode = ownerNodeForCall(builder, nested, component.node.id);
1423
+ addHttpClientUsage(builder, ownerNode.id, httpClientUsage, {
1424
+ ...(resourceLoaderFactory(nested)
1425
+ ? { resourceRole: 'loader' }
1426
+ : {}),
1427
+ });
1428
+ }
1429
+ addServerFunctionUsage(builder, nested, component.node.id);
1430
+ const temporalUsage = findCraftTemporalUsage(nested);
1431
+ if (temporalUsage) {
1432
+ const ownerNode = ownerNodeForCall(builder, nested, component.node.id);
1433
+ addTemporalUsage(builder, ownerNode.id, temporalUsage);
1434
+ }
1435
+ const helper = findServiceForCall(builder, nested) ??
1436
+ findComponentBoundService(component, nested);
1437
+ if (helper) {
1438
+ addEdge(builder, component.node.id, helper.node.id, 'depends-on', 'type');
1439
+ addServiceDependency(builder, component.node.id, helper, nested);
1440
+ if (!nearestPrimitiveFactory(nested)) {
1441
+ collectServiceBindings(component, nested, helper);
1442
+ }
1443
+ }
1444
+ if (isPrimitiveFactory(nested)) {
1445
+ addOwnedPrimitive(builder, nested, component.node.id, {
1446
+ usage: part === setup ? 'setup' : 'template',
1447
+ });
1448
+ }
1449
+ const child = findComponentForCall(builder, nested);
1450
+ if (child && child !== component) {
1451
+ addEdge(builder, component.node.id, child.node.id, 'renders', 'ast');
1452
+ }
1453
+ }
1454
+ addSourceInteractions(builder, component.node.id, part);
1455
+ }
1456
+ const setupBindings = setup
1457
+ ? collectReactiveBindings(builder, setup, component.node.id, component)
1458
+ : new Map();
1459
+ if (setup) {
1460
+ analyzeReactiveDependencies(builder, setup, setupBindings, component.node.id);
1461
+ }
1462
+ if (template) {
1463
+ analyzeTemplateDependencies(builder, component, template, setupBindings);
1464
+ }
1465
+ collectServicePropertyUses(builder, component);
1466
+ }
1467
+ }
1468
+ const NAMED_HTML_HELPERS = new Set([
1469
+ 'a',
1470
+ 'area',
1471
+ 'article',
1472
+ 'aside',
1473
+ 'button',
1474
+ 'caption',
1475
+ 'dialog',
1476
+ 'div',
1477
+ 'fieldset',
1478
+ 'figcaption',
1479
+ 'figure',
1480
+ 'footer',
1481
+ 'form',
1482
+ 'h1',
1483
+ 'h2',
1484
+ 'h3',
1485
+ 'h4',
1486
+ 'h5',
1487
+ 'h6',
1488
+ 'header',
1489
+ 'iframe',
1490
+ 'img',
1491
+ 'input',
1492
+ 'label',
1493
+ 'legend',
1494
+ 'li',
1495
+ 'main',
1496
+ 'nav',
1497
+ 'ol',
1498
+ 'option',
1499
+ 'p',
1500
+ 'pre',
1501
+ 'section',
1502
+ 'select',
1503
+ 'small',
1504
+ 'span',
1505
+ 'strong',
1506
+ 'svg',
1507
+ 'table',
1508
+ 'tbody',
1509
+ 'td',
1510
+ 'textarea',
1511
+ 'th',
1512
+ 'thead',
1513
+ 'tr',
1514
+ 'ul',
1515
+ ]);
1516
+ const INTERACTIVE_ELEMENT_TAGS = new Set([
1517
+ 'a',
1518
+ 'button',
1519
+ 'input',
1520
+ 'select',
1521
+ 'textarea',
1522
+ ]);
1523
+ const INTERACTIVE_ELEMENT_HANDLERS = new Set([
1524
+ 'click',
1525
+ 'onClick',
1526
+ 'input',
1527
+ 'onInput',
1528
+ 'change',
1529
+ 'onChange',
1530
+ 'submit',
1531
+ 'onSubmit',
1532
+ ]);
1533
+ function collectInteractiveTemplateElements(builder) {
1534
+ for (const component of builder.components) {
1535
+ const template = component.call.getArguments()[3];
1536
+ if (!template)
1537
+ continue;
1538
+ walkTemplate(template, (node) => {
1539
+ if (!Node.isCallExpression(node))
1540
+ return;
1541
+ if (node.getExpression().getText() === 'craftComponent')
1542
+ return 'skip';
1543
+ const parsed = parseCraftHyperscript(node);
1544
+ if (!parsed || !isInteractiveElement(parsed))
1545
+ return undefined;
1546
+ const filePath = node.getSourceFile().getFilePath();
1547
+ const element = addNode(builder, {
1548
+ id: `template-element:${filePath}:${node.getStartLineNumber()}:${node.getStart()}`,
1549
+ kind: 'template-element',
1550
+ label: parsed.name ?? '(unnamed)',
1551
+ filePath,
1552
+ line: node.getStartLineNumber(),
1553
+ details: {
1554
+ tag: parsed.tag,
1555
+ localName: parsed.name,
1556
+ static: parsed.nameKind !== 'non-static',
1557
+ missing: parsed.nameKind === 'missing',
1558
+ component: component.node.label,
1559
+ },
1560
+ });
1561
+ addEdge(builder, component.node.id, element.id, 'contains', 'ast');
1562
+ return undefined;
1563
+ });
1564
+ }
1565
+ }
1566
+ function walkTemplate(node, visit) {
1567
+ if (visit(node) === 'skip')
1568
+ return;
1569
+ node.forEachChild((child) => walkTemplate(child, visit));
1570
+ }
1571
+ function parseCraftHyperscript(call) {
1572
+ const callee = call.getExpression().getText();
1573
+ const args = call.getArguments();
1574
+ if (callee === 'h' || callee === 'customElement') {
1575
+ const tag = args[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
1576
+ if (!tag)
1577
+ return undefined;
1578
+ return {
1579
+ tag,
1580
+ nameKind: 'missing',
1581
+ props: args[1]?.asKind(SyntaxKind.ObjectLiteralExpression),
1582
+ };
1583
+ }
1584
+ if (!NAMED_HTML_HELPERS.has(callee))
1585
+ return undefined;
1586
+ const first = args[0];
1587
+ const second = args[1];
1588
+ if (first?.asKind(SyntaxKind.StringLiteral) &&
1589
+ (second?.asKind(SyntaxKind.ObjectLiteralExpression) ||
1590
+ second?.getKind() === SyntaxKind.NullKeyword)) {
1591
+ return {
1592
+ tag: callee,
1593
+ name: first.asKind(SyntaxKind.StringLiteral)?.getLiteralValue(),
1594
+ nameKind: 'literal',
1595
+ props: second.asKind(SyntaxKind.ObjectLiteralExpression),
1596
+ };
1597
+ }
1598
+ if (first && Node.isObjectLiteralExpression(first)) {
1599
+ return { tag: callee, nameKind: 'missing', props: first };
1600
+ }
1601
+ if (first &&
1602
+ (second?.asKind(SyntaxKind.ObjectLiteralExpression) ||
1603
+ second?.getKind() === SyntaxKind.NullKeyword) &&
1604
+ !first.asKind(SyntaxKind.StringLiteral)) {
1605
+ return {
1606
+ tag: callee,
1607
+ nameKind: 'non-static',
1608
+ props: second?.asKind(SyntaxKind.ObjectLiteralExpression),
1609
+ };
1610
+ }
1611
+ return { tag: callee, nameKind: 'missing' };
1612
+ }
1613
+ function isInteractiveElement(parsed) {
1614
+ if (parsed.tag === 'input' &&
1615
+ getStringProperty(parsed.props, 'type') === 'hidden') {
1616
+ return false;
1617
+ }
1618
+ if (INTERACTIVE_ELEMENT_TAGS.has(parsed.tag))
1619
+ return true;
1620
+ return hasInteractiveHandler(parsed.props);
1621
+ }
1622
+ function hasInteractiveHandler(props) {
1623
+ if (!props)
1624
+ return false;
1625
+ return [...INTERACTIVE_ELEMENT_HANDLERS].some((name) => props.getProperty(name) !== undefined);
1626
+ }
1627
+ function analyzeInsertions(builder) {
1628
+ const scopes = [];
1629
+ for (const service of builder.services) {
1630
+ const factory = service.call.getArguments()[1];
1631
+ if (factory)
1632
+ scopes.push({ node: factory, ownerId: service.node.id });
1633
+ }
1634
+ for (const component of builder.components) {
1635
+ const setup = component.call.getArguments()[2];
1636
+ if (setup)
1637
+ scopes.push({ node: setup, ownerId: component.node.id });
1638
+ }
1639
+ for (const { node, ownerId } of scopes) {
1640
+ for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1641
+ const name = call.getExpression().getText();
1642
+ if (name === 'insertReactOnMutation') {
1643
+ addReactOnMutation(builder, call, ownerId);
1644
+ }
1645
+ if (STORAGE_PERSISTER_INSERTIONS.has(name)) {
1646
+ addStoragePersister(builder, call, ownerId);
1647
+ }
1648
+ }
1649
+ }
1650
+ }
1651
+ function addReactOnMutation(builder, call, ownerId) {
1652
+ const host = nearestHostPrimitive(builder, call, ownerId, new Set(['query']));
1653
+ const mutationName = identifierText(call.getArguments()[0]);
1654
+ if (!host || !mutationName)
1655
+ return;
1656
+ const mutationNode = findOwnedPrimitive(builder, ownerId, mutationName, 'mutation');
1657
+ if (!mutationNode)
1658
+ return;
1659
+ addEdge(builder, mutationNode.id, host.id, 'triggers', 'ast', {
1660
+ insertion: 'react-on-mutation',
1661
+ line: call.getStartLineNumber(),
1662
+ });
1663
+ }
1664
+ function addStoragePersister(builder, call, ownerId) {
1665
+ const host = nearestHostPrimitive(builder, call, ownerId);
1666
+ if (!host)
1667
+ return;
1668
+ const uniqueArgument = unwrapExpression(call.getArguments()[0]);
1669
+ const uniqueCall = uniqueArgument?.isKind(SyntaxKind.CallExpression) &&
1670
+ uniqueArgument.getExpression().getText() === 'craftUnique'
1671
+ ? uniqueArgument
1672
+ : undefined;
1673
+ host.details = {
1674
+ ...(host.details ?? {}),
1675
+ persisted: true,
1676
+ persistedUnique: Boolean(uniqueCall),
1677
+ };
1678
+ }
1679
+ function nearestHostPrimitive(builder, node, aggregateOwnerId, hosts = HOST_PRIMITIVES) {
1680
+ let current = node.getParent();
1681
+ while (current) {
1682
+ if (Node.isCallExpression(current) && isPrimitiveFactory(current)) {
1683
+ const primitive = primitiveFactoryName(current);
1684
+ if (primitive && hosts.has(primitive)) {
1685
+ return addPrimitiveNode(builder, current, primitive, aggregateOwnerId);
1686
+ }
1687
+ }
1688
+ current = current.getParent();
1689
+ }
1690
+ return undefined;
1691
+ }
1692
+ function findOwnedPrimitive(builder, ownerId, name, primitive) {
1693
+ return [...builder.nodes.values()].find((node) => node.kind === 'primitive' &&
1694
+ node.details?.['ownerId'] === ownerId &&
1695
+ (primitive === undefined || node.details?.['primitive'] === primitive) &&
1696
+ (node.details?.['usage'] === name || node.details?.['name'] === name));
1697
+ }
1698
+ function identifierText(node) {
1699
+ const current = unwrapExpression(node);
1700
+ if (!current)
1701
+ return undefined;
1702
+ if (Node.isIdentifier(current))
1703
+ return current.getText();
1704
+ return undefined;
1705
+ }
1706
+ function analyzeRoutes(builder) {
1707
+ for (const routeInfos of builder.routeFiles.values()) {
1708
+ for (const route of routeInfos) {
1709
+ for (const property of route.object.getProperties()) {
1710
+ if (!Node.isPropertyAssignment(property))
1711
+ continue;
1712
+ if (property.getName() === 'component') {
1713
+ const component = findComponentForExpression(builder, property.getInitializer());
1714
+ if (component) {
1715
+ addEdge(builder, route.node.id, component.node.id, 'loads', 'ast');
1716
+ }
1717
+ }
1718
+ if (property.getName() === 'loadComponent') {
1719
+ for (const identifier of property.getDescendantsOfKind(SyntaxKind.Identifier)) {
1720
+ const component = findComponentForExpression(builder, identifier);
1721
+ if (component) {
1722
+ addEdge(builder, route.node.id, component.node.id, 'loads', 'ast');
1723
+ }
1724
+ }
1725
+ }
1726
+ }
1727
+ const imports = findDynamicImportSpecifiers(route.object);
1728
+ for (const specifier of imports) {
1729
+ const target = resolveImportedSource(route.sourceFile, specifier, builder.project);
1730
+ if (!target)
1731
+ continue;
1732
+ const exportNames = findDynamicImportExportNames(route.object, specifier);
1733
+ for (const component of builder.components.filter((candidate) => candidate.node.filePath === target.getFilePath() &&
1734
+ (exportNames === undefined ||
1735
+ exportNames.length === 0 ||
1736
+ componentExportNames(candidate).some((name) => exportNames.includes(name))))) {
1737
+ addEdge(builder, route.node.id, component.node.id, 'loads', 'ast');
1738
+ }
1739
+ for (const childRoute of builder.routeFiles.get(target.getFilePath()) ??
1740
+ []) {
1741
+ addEdge(builder, route.node.id, childRoute.node.id, 'loads', 'ast');
1742
+ }
1743
+ }
1744
+ for (const property of route.object.getProperties()) {
1745
+ if (!Node.isPropertyAssignment(property))
1746
+ continue;
1747
+ const name = property.getName();
1748
+ if (![
1749
+ 'canActivate',
1750
+ 'canMatch',
1751
+ 'resolve',
1752
+ 'queryParams',
1753
+ 'handleExceptions',
1754
+ ].includes(name)) {
1755
+ continue;
1756
+ }
1757
+ const hook = addNode(builder, {
1758
+ id: `route-hook:${route.node.id}:${name}`,
1759
+ kind: 'route-hook',
1760
+ label: `${route.node.label}.${name}`,
1761
+ filePath: route.sourceFile.getFilePath(),
1762
+ line: property.getStartLineNumber(),
1763
+ });
1764
+ addEdge(builder, route.node.id, hook.id, 'contains', 'ast');
1765
+ collectRouteHookServiceDependencies(builder, hook.id, property);
1766
+ }
1767
+ }
1768
+ }
1769
+ }
1770
+ function collectRouteHookServiceDependencies(builder, hookId, property) {
1771
+ const visitedDeclarations = new Set();
1772
+ const pendingScopes = [property];
1773
+ while (pendingScopes.length > 0) {
1774
+ const scope = pendingScopes.shift();
1775
+ if (!scope)
1776
+ continue;
1777
+ for (const call of scope.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1778
+ const httpClientUsage = findCraftHttpClientUsage(call);
1779
+ if (httpClientUsage)
1780
+ addHttpClientUsage(builder, hookId, httpClientUsage);
1781
+ const temporalUsage = findCraftTemporalUsage(call);
1782
+ if (temporalUsage)
1783
+ addTemporalUsage(builder, hookId, temporalUsage);
1784
+ const helper = findServiceForCall(builder, call);
1785
+ if (helper) {
1786
+ addEdge(builder, hookId, helper.node.id, 'depends-on', 'type');
1787
+ }
1788
+ for (const declaration of resolveCallableDeclarations(call)) {
1789
+ if (declaration.getSourceFile().isDeclarationFile())
1790
+ continue;
1791
+ const declarationKey = `${declaration.getSourceFile().getFilePath()}:${declaration.getStart()}`;
1792
+ if (visitedDeclarations.has(declarationKey))
1793
+ continue;
1794
+ visitedDeclarations.add(declarationKey);
1795
+ pendingScopes.push(declaration);
1796
+ }
1797
+ }
1798
+ }
1799
+ }
1800
+ function resolveCallableDeclarations(call) {
1801
+ const identifier = rootIdentifier(call.getExpression());
1802
+ const symbol = identifier?.getSymbol();
1803
+ const resolved = symbol?.getAliasedSymbol() ?? symbol;
1804
+ return resolved?.getDeclarations() ?? [];
1805
+ }
1806
+ function analyzeRouteObject(builder, route) {
1807
+ collectProvides(builder, route.node.id, route.object);
1808
+ }
1809
+ function collectProvides(builder, ownerId, object) {
1810
+ if (!object || !Node.isObjectLiteralExpression(object))
1811
+ return;
1812
+ const property = object.getProperty('providers');
1813
+ if (!property || !Node.isPropertyAssignment(property))
1814
+ return;
1815
+ for (const call of property.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1816
+ const helper = findServiceForCall(builder, call);
1817
+ if (!helper)
1818
+ continue;
1819
+ addEdge(builder, ownerId, helper.node.id, 'provides', 'type', {
1820
+ helper: call.getExpression().getText(),
1821
+ });
1822
+ }
1823
+ }
1824
+ function collectServiceBindings(component, serviceCall, service) {
1825
+ const yieldExpression = serviceCall.getFirstAncestorByKind(SyntaxKind.YieldExpression);
1826
+ const declaration = yieldExpression?.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1827
+ if (declaration) {
1828
+ for (const name of getBindingNames(declaration.getNameNode()))
1829
+ component.bindings.set(name, service);
1830
+ }
1831
+ const property = yieldExpression?.getFirstAncestorByKind(SyntaxKind.PropertyAssignment);
1832
+ if (property)
1833
+ component.bindings.set(property.getName(), service);
1834
+ }
1835
+ function collectServiceBindingsFromReturns(component, part, builder) {
1836
+ for (const yieldExpression of part.getDescendantsOfKind(SyntaxKind.YieldExpression)) {
1837
+ const expression = yieldExpression.getExpression();
1838
+ const call = expression?.asKind(SyntaxKind.CallExpression);
1839
+ if (!call)
1840
+ continue;
1841
+ if (nearestPrimitiveFactory(call))
1842
+ continue;
1843
+ const service = findServiceForCall(builder, call);
1844
+ if (service)
1845
+ collectServiceBindings(component, call, service);
1846
+ }
1847
+ }
1848
+ function collectServicePropertyUses(builder, component) {
1849
+ const parts = [
1850
+ [component.call.getArguments()[2], 'setup'],
1851
+ [component.call.getArguments()[3], 'template'],
1852
+ ];
1853
+ for (const [part, usage] of parts) {
1854
+ if (!part)
1855
+ continue;
1856
+ for (const access of part.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
1857
+ const chain = propertyAccessChain(access);
1858
+ if (!chain)
1859
+ continue;
1860
+ const service = component.bindings.get(chain[0]);
1861
+ if (!service || chain.length < 2)
1862
+ continue;
1863
+ let parentId = service.node.id;
1864
+ let currentType = service.outputType;
1865
+ for (const [index, propertyName] of chain.slice(1).entries()) {
1866
+ if (index > 0 && NON_DEPENDENCY_PROPERTY_NAMES.has(propertyName))
1867
+ break;
1868
+ if (index > 0 && currentType?.isArray())
1869
+ break;
1870
+ const property = currentType?.getProperty(propertyName);
1871
+ const propertyType = property?.getTypeAtLocation(access);
1872
+ const propertyPath = chain
1873
+ .slice(1, chain.indexOf(propertyName) + 1)
1874
+ .join('.');
1875
+ const propertyId = `property:${service.node.id}:${propertyPath}`;
1876
+ const propertyNode = addNode(builder, {
1877
+ id: propertyId,
1878
+ kind: 'property',
1879
+ label: `${service.node.label}.${propertyPath}`,
1880
+ filePath: component.node.filePath,
1881
+ line: access.getStartLineNumber(),
1882
+ details: {
1883
+ type: propertyType?.getText(access),
1884
+ declaredOnServiceType: Boolean(property),
1885
+ usedIn: usage,
1886
+ },
1887
+ });
1888
+ mergeUsageDetail(propertyNode, 'usedIn', usage);
1889
+ addEdge(builder, parentId, propertyNode.id, 'contains', 'type', {
1890
+ property: propertyName,
1891
+ });
1892
+ addEdge(builder, component.node.id, propertyNode.id, 'uses-property', 'type', {
1893
+ property: propertyName,
1894
+ usage,
1895
+ });
1896
+ parentId = propertyNode.id;
1897
+ currentType = propertyType;
1898
+ }
1899
+ }
1900
+ }
1901
+ }
1902
+ function addSourceInteractions(builder, ownerId, node) {
1903
+ for (const source of builder.sources) {
1904
+ for (const name of source.variableNames) {
1905
+ for (const access of node.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
1906
+ const text = access.getText();
1907
+ const directSourceAccess = text.startsWith(`${name}.`);
1908
+ const exposedMachineSourceAccess = isExposedMachineSourceAccess(source, access, name);
1909
+ if (!directSourceAccess && !exposedMachineSourceAccess)
1910
+ continue;
1911
+ const owner = ownerNodeForAst(builder, access, ownerId);
1912
+ if (text.endsWith('.emit') || text.endsWith('.set')) {
1913
+ addEdge(builder, owner.id, source.node.id, 'writes', 'ast', {
1914
+ operation: text.split('.').pop(),
1915
+ ...(exposedMachineSourceAccess
1916
+ ? { exposedThrough: 'state-machine' }
1917
+ : {}),
1918
+ });
1919
+ }
1920
+ else if (text.endsWith('.subscribe') ||
1921
+ text.endsWith('.asReadonly')) {
1922
+ addEdge(builder, owner.id, source.node.id, 'subscribes', 'ast');
1923
+ }
1924
+ else {
1925
+ addEdge(builder, owner.id, source.node.id, 'reads', 'ast');
1926
+ }
1927
+ }
1928
+ for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1929
+ const expression = call.getExpression().getText();
1930
+ if (expression !== 'on$' && expression !== 'afterRecomputation')
1931
+ continue;
1932
+ if (call.getArguments()[0]?.getText() === name) {
1933
+ const primitive = nearestPrimitiveFactory(call);
1934
+ addEdge(builder, source.node.id, primitive ? primitiveNodeId(builder, primitive) : ownerId, primitive ? 'triggers' : 'subscribes', 'ast');
1935
+ }
1936
+ }
1937
+ }
1938
+ }
1939
+ }
1940
+ /**
1941
+ * A state-machine insertion can expose a source under an alias such as
1942
+ * `machine.change$`. The source declaration is still the one nested in the
1943
+ * machine context factory, so connect that exposed access back to the source
1944
+ * node instead of leaving the graph at an anonymous machine property.
1945
+ */
1946
+ function isExposedMachineSourceAccess(source, access, sourceName) {
1947
+ const chain = propertyAccessChain(access);
1948
+ if (!chain || chain.length < 3 || !chain.includes(sourceName))
1949
+ return false;
1950
+ const machineCall = nearestPrimitiveFactory(source.call);
1951
+ if (!machineCall ||
1952
+ primitiveFactoryName(machineCall) !== 'craftStateMachine') {
1953
+ return false;
1954
+ }
1955
+ const machineDeclaration = machineCall.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1956
+ const machineNames = new Set();
1957
+ if (machineDeclaration) {
1958
+ addBindingNames(machineDeclaration.getNameNode(), machineNames);
1959
+ }
1960
+ if (!machineNames.has(chain[0] ?? ''))
1961
+ return false;
1962
+ const sourceIndex = chain.indexOf(sourceName);
1963
+ return sourceIndex > 0 && chain[sourceIndex + 1] === 'emit';
1964
+ }
1965
+ function primitiveNodeId(builder, call) {
1966
+ const owner = nearestPrimitiveFactory(call) ?? call;
1967
+ const primitive = primitiveFactoryName(owner) ?? primitiveName(owner) ?? 'primitive';
1968
+ const sourceFile = call.getSourceFile();
1969
+ return `primitive:${sourceFile.getFilePath()}:${primitive}:${owner.getStartLineNumber()}`;
1970
+ }
1971
+ function ownerNodeForCall(builder, call, aggregateOwnerId) {
1972
+ return ownerNodeForAst(builder, call, aggregateOwnerId);
1973
+ }
1974
+ function ownerNodeForAst(builder, node, aggregateOwnerId) {
1975
+ const ownerPrimitive = nearestPrimitiveFactory(node);
1976
+ const ownerPrimitiveName = ownerPrimitive && primitiveFactoryName(ownerPrimitive);
1977
+ return ownerPrimitive && ownerPrimitiveName
1978
+ ? addPrimitiveNode(builder, ownerPrimitive, ownerPrimitiveName, aggregateOwnerId)
1979
+ : (builder.nodes.get(aggregateOwnerId) ?? {
1980
+ id: aggregateOwnerId,
1981
+ kind: 'service',
1982
+ label: aggregateOwnerId,
1983
+ });
1984
+ }
1985
+ function addOwnedPrimitive(builder, call, aggregateOwnerId, details) {
1986
+ const primitive = primitiveFactoryName(call);
1987
+ if (!primitive)
1988
+ return undefined;
1989
+ const primitiveNode = addPrimitiveNode(builder, call, primitive, aggregateOwnerId);
1990
+ recordEffectLoaderRequirements(primitiveNode, call, primitive);
1991
+ const enclosing = nearestPrimitiveFactory(call);
1992
+ const enclosingName = enclosing && primitiveFactoryName(enclosing);
1993
+ const parentId = enclosing && enclosingName
1994
+ ? addPrimitiveNode(builder, enclosing, enclosingName, aggregateOwnerId).id
1995
+ : aggregateOwnerId;
1996
+ addEdge(builder, parentId, primitiveNode.id, 'contains', 'ast', details);
1997
+ return primitiveNode;
1998
+ }
1999
+ function recordEffectLoaderRequirements(primitiveNode, call, primitive) {
2000
+ if (primitive !== 'queryEffect' &&
2001
+ primitive !== 'mutationEffect' &&
2002
+ primitive !== 'computedEffect') {
2003
+ return;
2004
+ }
2005
+ const config = call
2006
+ .getArguments()[1]
2007
+ ?.asKind(SyntaxKind.ObjectLiteralExpression);
2008
+ const callbacks = primitive === 'computedEffect'
2009
+ ? [['computed', call.getArguments()[1]]]
2010
+ : [
2011
+ [
2012
+ 'loader',
2013
+ config
2014
+ ?.getProperty('loader')
2015
+ ?.asKind(SyntaxKind.PropertyAssignment)
2016
+ ?.getInitializer(),
2017
+ ],
2018
+ [
2019
+ 'params',
2020
+ config
2021
+ ?.getProperty('params')
2022
+ ?.asKind(SyntaxKind.PropertyAssignment)
2023
+ ?.getInitializer(),
2024
+ ],
2025
+ [
2026
+ 'method',
2027
+ config
2028
+ ?.getProperty('method')
2029
+ ?.asKind(SyntaxKind.PropertyAssignment)
2030
+ ?.getInitializer(),
2031
+ ],
2032
+ ];
2033
+ for (const [role, callback] of callbacks) {
2034
+ const returnType = callback
2035
+ ?.getType()
2036
+ .getCallSignatures()[0]
2037
+ ?.getReturnType();
2038
+ const effectType = (role === 'computed' || role === 'params') &&
2039
+ returnType?.getSymbol()?.getName() === 'Generator'
2040
+ ? returnType.getTypeArguments()[1]
2041
+ : returnType;
2042
+ const requirementType = effectType?.getTypeArguments()[2];
2043
+ if (!requirementType)
2044
+ continue;
2045
+ const requirements = requirementType.isUnion()
2046
+ ? requirementType.getUnionTypes()
2047
+ : [requirementType];
2048
+ const names = requirements
2049
+ .filter((type) => !['never', 'unknown', 'undefined'].includes(type.getText()))
2050
+ .map((type) => type.getSymbol()?.getName() ??
2051
+ type.getAliasSymbol()?.getName() ??
2052
+ type.getText().split('.').at(-1))
2053
+ .filter((name) => Boolean(name));
2054
+ if (names.length > 0) {
2055
+ primitiveNode.details = {
2056
+ ...(primitiveNode.details ?? {}),
2057
+ [`${role}Requirements`]: [...new Set(names)],
2058
+ };
2059
+ }
2060
+ }
2061
+ }
2062
+ function linkEffectLoaderRequirements(builder) {
2063
+ const effectServices = new Map([...builder.nodes.values()]
2064
+ .filter((node) => node.kind === 'service' && node.details?.['runtime'] === 'effect')
2065
+ .map((node) => [node.label, node]));
2066
+ for (const primitive of builder.nodes.values()) {
2067
+ if (primitive.kind !== 'primitive') {
2068
+ continue;
2069
+ }
2070
+ for (const role of ['loader', 'params', 'method', 'computed']) {
2071
+ const requirements = primitive.details?.[`${role}Requirements`];
2072
+ if (!Array.isArray(requirements) ||
2073
+ !requirements.every((value) => typeof value === 'string')) {
2074
+ continue;
2075
+ }
2076
+ for (const requirement of requirements) {
2077
+ const service = effectServices.get(requirement);
2078
+ if (!service)
2079
+ continue;
2080
+ addEdge(builder, primitive.id, service.id, 'depends-on', 'type', {
2081
+ runtime: 'effect',
2082
+ effectRequirement: true,
2083
+ resourceRole: role,
2084
+ });
2085
+ }
2086
+ }
2087
+ }
2088
+ }
2089
+ function collectReactiveBindings(builder, scope, ownerId, component) {
2090
+ const bindings = new Map();
2091
+ for (const call of scope.getDescendantsOfKind(SyntaxKind.CallExpression)) {
2092
+ if (!isPrimitiveFactory(call))
2093
+ continue;
2094
+ const primitive = primitiveFactoryName(call);
2095
+ if (!primitive)
2096
+ continue;
2097
+ const primitiveNode = addPrimitiveNode(builder, call, primitive, ownerId);
2098
+ const declaration = initializerDeclaration(call);
2099
+ const declarationName = declaration?.getNameNode();
2100
+ if (declarationName && Node.isIdentifier(declarationName)) {
2101
+ bindings.set(declarationName.getText(), {
2102
+ primitiveId: primitiveNode.id,
2103
+ });
2104
+ }
2105
+ const property = initializerProperty(call);
2106
+ if (property) {
2107
+ bindings.set(property.getName(), { primitiveId: primitiveNode.id });
2108
+ }
2109
+ }
2110
+ if (component) {
2111
+ for (const [name, service] of component.bindings) {
2112
+ const primitive = findPrimitiveByName(builder, service.node.id, name);
2113
+ if (primitive) {
2114
+ bindings.set(name, { primitiveId: primitive.id, service });
2115
+ continue;
2116
+ }
2117
+ const isKnownMember = service.outputPropertyNames.size === 0 ||
2118
+ service.outputPropertyNames.has(name);
2119
+ if (!isKnownMember) {
2120
+ bindings.set(name, { primitiveId: service.node.id, service });
2121
+ continue;
2122
+ }
2123
+ bindings.set(name, {
2124
+ primitiveId: `property:${service.node.id}:${name}`,
2125
+ service,
2126
+ });
2127
+ addNode(builder, {
2128
+ id: `property:${service.node.id}:${name}`,
2129
+ kind: 'property',
2130
+ label: `${service.node.label}.${name}`,
2131
+ filePath: service.node.filePath,
2132
+ line: service.node.line,
2133
+ details: { member: name },
2134
+ });
2135
+ addEdge(builder, service.node.id, `property:${service.node.id}:${name}`, 'contains', 'type', {
2136
+ property: name,
2137
+ });
2138
+ }
2139
+ }
2140
+ return bindings;
2141
+ }
2142
+ function analyzeReactiveDependencies(builder, scope, bindings, aggregateOwnerId, component) {
2143
+ for (const call of scope.getDescendantsOfKind(SyntaxKind.CallExpression)) {
2144
+ if (!isTrackedReactiveHost(call))
2145
+ continue;
2146
+ const host = addOwnedPrimitive(builder, call, aggregateOwnerId);
2147
+ if (!host)
2148
+ continue;
2149
+ const body = reactiveHostBody(call);
2150
+ if (!body)
2151
+ continue;
2152
+ for (const expression of collectReactiveExpressions(body)) {
2153
+ const target = resolveReactiveTarget(builder, expression, bindings, component, aggregateOwnerId);
2154
+ if (!target || target.id === host.id)
2155
+ continue;
2156
+ addEdge(builder, host.id, target.id, target.kind, 'ast', target.details);
2157
+ }
2158
+ }
2159
+ }
2160
+ function analyzeTemplateDependencies(builder, component, template, bindings) {
2161
+ const parameterNames = templateParameterNames(template);
2162
+ for (const expression of collectReactiveExpressions(template)) {
2163
+ if (Node.isIdentifier(expression) &&
2164
+ parameterNames.has(expression.getText()) &&
2165
+ isBindingName(expression)) {
2166
+ continue;
2167
+ }
2168
+ const target = resolveReactiveTarget(builder, expression, bindings, component, component.node.id);
2169
+ if (!target)
2170
+ continue;
2171
+ const kind = target.kind === 'calls' ? 'calls' : 'uses-property';
2172
+ addEdge(builder, component.node.id, target.id, kind, 'ast', {
2173
+ ...target.details,
2174
+ usage: 'template',
2175
+ });
2176
+ }
2177
+ }
2178
+ function collectReactiveExpressions(scope) {
2179
+ const expressions = [];
2180
+ const seen = new Set();
2181
+ const add = (node) => {
2182
+ if (!node || seen.has(node))
2183
+ return;
2184
+ seen.add(node);
2185
+ expressions.push(node);
2186
+ };
2187
+ for (const yieldExpression of scope.getDescendantsOfKind(SyntaxKind.YieldExpression)) {
2188
+ add(unwrapExpression(yieldExpression.getExpression()));
2189
+ }
2190
+ for (const call of scope.getDescendantsOfKind(SyntaxKind.CallExpression)) {
2191
+ if (isPrimitiveFactory(call) ||
2192
+ findCraftHttpClientUsage(call) ||
2193
+ findCraftTemporalUsage(call)) {
2194
+ continue;
2195
+ }
2196
+ add(call);
2197
+ }
2198
+ for (const access of scope.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
2199
+ if (access.getParent()?.isKind(SyntaxKind.CallExpression))
2200
+ continue;
2201
+ add(access);
2202
+ }
2203
+ for (const identifier of scope.getDescendantsOfKind(SyntaxKind.Identifier)) {
2204
+ if (isBindingName(identifier))
2205
+ continue;
2206
+ if (identifier.getParent()?.isKind(SyntaxKind.PropertyAccessExpression))
2207
+ continue;
2208
+ if (identifier.getParent()?.isKind(SyntaxKind.CallExpression))
2209
+ continue;
2210
+ add(identifier);
2211
+ }
2212
+ return expressions;
2213
+ }
2214
+ function resolveReactiveTarget(builder, expression, bindings, component, aggregateOwnerId) {
2215
+ const unwrapped = unwrapExpression(expression);
2216
+ if (!unwrapped)
2217
+ return undefined;
2218
+ if (Node.isCallExpression(unwrapped)) {
2219
+ const callee = unwrapped.getExpression();
2220
+ const wrapperName = Node.isIdentifier(callee)
2221
+ ? callee.getText()
2222
+ : undefined;
2223
+ if (wrapperName && REACTIVE_WRAPPER_NAMES.has(wrapperName)) {
2224
+ return resolveReactiveTarget(builder, unwrapped.getArguments()[0], bindings, component, aggregateOwnerId);
2225
+ }
2226
+ const chain = Node.isIdentifier(callee)
2227
+ ? [callee.getText()]
2228
+ : Node.isPropertyAccessExpression(callee)
2229
+ ? propertyAccessChain(callee)
2230
+ : undefined;
2231
+ if (!chain)
2232
+ return undefined;
2233
+ return resolveReactiveChain(builder, chain, unwrapped, bindings, component, aggregateOwnerId);
2234
+ }
2235
+ if (Node.isIdentifier(unwrapped)) {
2236
+ return resolveReactiveChain(builder, [unwrapped.getText()], unwrapped, bindings, component, aggregateOwnerId);
2237
+ }
2238
+ if (Node.isPropertyAccessExpression(unwrapped)) {
2239
+ const chain = propertyAccessChain(unwrapped);
2240
+ if (!chain)
2241
+ return undefined;
2242
+ return resolveReactiveChain(builder, chain, unwrapped, bindings, component, aggregateOwnerId);
2243
+ }
2244
+ return undefined;
2245
+ }
2246
+ function resolveReactiveChain(builder, chain, node, bindings, component, aggregateOwnerId) {
2247
+ const [root, ...rest] = chain;
2248
+ if (!root)
2249
+ return undefined;
2250
+ if (INSERTION_CONTEXT_NAMES.has(root)) {
2251
+ const enclosing = enclosingPrimitiveNode(builder, node, aggregateOwnerId);
2252
+ if (enclosing) {
2253
+ if (rest.length === 0) {
2254
+ return {
2255
+ id: enclosing.id,
2256
+ kind: isLikelyMethod(rest, node, builder, enclosing.id)
2257
+ ? 'calls'
2258
+ : 'depends-on',
2259
+ details: { reader: root },
2260
+ };
2261
+ }
2262
+ const propertyNode = addPrimitiveMemberProperty(builder, enclosing.id, rest.join('.'), node);
2263
+ return {
2264
+ id: propertyNode.id,
2265
+ kind: isLikelyMethod(rest, node, builder, enclosing.id)
2266
+ ? 'calls'
2267
+ : 'depends-on',
2268
+ details: { path: rest.join('.') },
2269
+ };
2270
+ }
2271
+ }
2272
+ const binding = bindings.get(root);
2273
+ const service = binding?.service ?? component?.bindings.get(root);
2274
+ const method = isLikelyMethod(rest, node, builder, binding?.primitiveId);
2275
+ if (binding?.primitiveId && builder.nodes.has(binding.primitiveId)) {
2276
+ if (rest.length === 0) {
2277
+ const primitive = builder.nodes.get(binding.primitiveId);
2278
+ const isMethod = method || primitive?.details?.['primitive'] === 'craftMethod';
2279
+ return {
2280
+ id: binding.primitiveId,
2281
+ kind: isMethod ? 'calls' : 'depends-on',
2282
+ details: { reader: root },
2283
+ };
2284
+ }
2285
+ const memberPrimitive = INSERTION_CONTEXT_NAMES.has(root)
2286
+ ? undefined
2287
+ : findMemberPrimitive(builder, binding.primitiveId, rest[0]);
2288
+ if (memberPrimitive) {
2289
+ if (rest.length === 1) {
2290
+ return {
2291
+ id: memberPrimitive.id,
2292
+ kind: method ? 'calls' : 'depends-on',
2293
+ details: { path: rest.join('.') },
2294
+ };
2295
+ }
2296
+ const propertyNode = addPrimitiveMemberProperty(builder, memberPrimitive.id, rest.slice(1).join('.'), node);
2297
+ return {
2298
+ id: propertyNode.id,
2299
+ kind: method ? 'calls' : 'depends-on',
2300
+ details: { path: rest.join('.') },
2301
+ };
2302
+ }
2303
+ const propertyNode = addPrimitiveMemberProperty(builder, binding.primitiveId, rest.join('.'), node);
2304
+ return {
2305
+ id: propertyNode.id,
2306
+ kind: method ? 'calls' : 'depends-on',
2307
+ details: { path: rest.join('.') },
2308
+ };
2309
+ }
2310
+ if (service) {
2311
+ if (rest.length === 0) {
2312
+ const primitive = findPrimitiveByName(builder, service.node.id, root);
2313
+ if (primitive) {
2314
+ return { id: primitive.id, kind: method ? 'calls' : 'depends-on' };
2315
+ }
2316
+ const propertyNode = addServiceMemberProperty(builder, service, root, node);
2317
+ return { id: propertyNode.id, kind: method ? 'calls' : 'depends-on' };
2318
+ }
2319
+ const rootPrimitive = findPrimitiveByName(builder, service.node.id, root);
2320
+ if (rootPrimitive) {
2321
+ const memberPrimitive = findMemberPrimitive(builder, rootPrimitive.id, rest[0]);
2322
+ if (memberPrimitive) {
2323
+ if (rest.length === 1) {
2324
+ return {
2325
+ id: memberPrimitive.id,
2326
+ kind: method ? 'calls' : 'depends-on',
2327
+ details: { path: rest.join('.') },
2328
+ };
2329
+ }
2330
+ const nestedProperty = addPrimitiveMemberProperty(builder, memberPrimitive.id, rest.slice(1).join('.'), node);
2331
+ return {
2332
+ id: nestedProperty.id,
2333
+ kind: method ? 'calls' : 'depends-on',
2334
+ details: { path: rest.join('.') },
2335
+ };
2336
+ }
2337
+ const propertyNode = addPrimitiveMemberProperty(builder, rootPrimitive.id, rest.join('.'), node);
2338
+ return {
2339
+ id: propertyNode.id,
2340
+ kind: method ? 'calls' : 'depends-on',
2341
+ details: { path: rest.join('.') },
2342
+ };
2343
+ }
2344
+ const propertyNode = addServiceMemberProperty(builder, service, rest.join('.'), node);
2345
+ return {
2346
+ id: propertyNode.id,
2347
+ kind: method ? 'calls' : 'depends-on',
2348
+ details: { path: rest.join('.') },
2349
+ };
2350
+ }
2351
+ return undefined;
2352
+ }
2353
+ function addPrimitiveMemberProperty(builder, primitiveId, memberPath, node) {
2354
+ const primitive = builder.nodes.get(primitiveId);
2355
+ const propertyNode = addNode(builder, {
2356
+ id: `property:${primitiveId}:${memberPath}`,
2357
+ kind: 'property',
2358
+ label: `${primitive?.label ?? primitiveId}.${memberPath}`,
2359
+ filePath: node.getSourceFile().getFilePath(),
2360
+ line: node.getStartLineNumber(),
2361
+ details: { member: memberPath },
2362
+ });
2363
+ addEdge(builder, primitiveId, propertyNode.id, 'contains', 'ast', {
2364
+ property: memberPath.split('.')[0],
2365
+ });
2366
+ return propertyNode;
2367
+ }
2368
+ function addServiceMemberProperty(builder, service, memberPath, node) {
2369
+ const propertyNode = addNode(builder, {
2370
+ id: `property:${service.node.id}:${memberPath}`,
2371
+ kind: 'property',
2372
+ label: `${service.node.label}.${memberPath}`,
2373
+ filePath: service.node.filePath,
2374
+ line: node.getStartLineNumber(),
2375
+ details: { member: memberPath },
2376
+ });
2377
+ addEdge(builder, service.node.id, propertyNode.id, 'contains', 'type', {
2378
+ member: memberPath,
2379
+ });
2380
+ return propertyNode;
2381
+ }
2382
+ function enclosingPrimitiveNode(builder, node, aggregateOwnerId) {
2383
+ let current = node.getParent();
2384
+ while (current) {
2385
+ if (Node.isCallExpression(current) && isPrimitiveFactory(current)) {
2386
+ const primitive = primitiveFactoryName(current);
2387
+ if (primitive &&
2388
+ primitive !== 'craftComputed' &&
2389
+ primitive !== 'craftMethod' &&
2390
+ primitive !== 'craftEffect') {
2391
+ return addPrimitiveNode(builder, current, primitive, aggregateOwnerId);
2392
+ }
2393
+ }
2394
+ current = current.getParent();
2395
+ }
2396
+ return undefined;
2397
+ }
2398
+ function findPrimitiveByName(builder, ownerId, name) {
2399
+ return [...builder.nodes.values()].find((node) => node.kind === 'primitive' &&
2400
+ node.details?.['ownerId'] === ownerId &&
2401
+ node.details?.['name'] === name);
2402
+ }
2403
+ function findMemberPrimitive(builder, parentPrimitiveId, memberName) {
2404
+ for (const edge of builder.edges.values()) {
2405
+ if (edge.kind !== 'contains' || edge.from !== parentPrimitiveId)
2406
+ continue;
2407
+ const child = builder.nodes.get(edge.to);
2408
+ if (child?.kind === 'primitive' &&
2409
+ (child.details?.['usage'] === memberName ||
2410
+ child.details?.['name'] === memberName)) {
2411
+ return child;
2412
+ }
2413
+ }
2414
+ return undefined;
2415
+ }
2416
+ function isLikelyMethod(path, node, builder, primitiveId) {
2417
+ const leaf = path[path.length - 1];
2418
+ if (leaf && REACTIVE_METHOD_NAMES.has(leaf))
2419
+ return true;
2420
+ if (leaf && REACTIVE_READER_NAMES.has(leaf))
2421
+ return false;
2422
+ if (primitiveId) {
2423
+ const member = leaf
2424
+ ? findMemberPrimitive(builder, primitiveId, leaf)
2425
+ : undefined;
2426
+ if (member?.details?.['primitive'] === 'craftMethod')
2427
+ return true;
2428
+ }
2429
+ return (Node.isCallExpression(node) &&
2430
+ node.getArguments().length > 0 &&
2431
+ !REACTIVE_READER_NAMES.has(leaf ?? ''));
2432
+ }
2433
+ function isTrackedReactiveHost(call) {
2434
+ const name = primitiveFactoryName(call);
2435
+ return (name === 'craftComputed' || name === 'craftMethod' || name === 'craftEffect');
2436
+ }
2437
+ function reactiveHostBody(call) {
2438
+ return (call
2439
+ .getArguments()
2440
+ .find((argument) => argument.isKind(SyntaxKind.ArrowFunction) ||
2441
+ argument.isKind(SyntaxKind.FunctionExpression)) ?? call.getArguments().at(-1));
2442
+ }
2443
+ function initializerDeclaration(call) {
2444
+ const yieldExpression = call.getFirstAncestorByKind(SyntaxKind.YieldExpression);
2445
+ const declaration = (yieldExpression ?? call).getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
2446
+ if (!declaration)
2447
+ return undefined;
2448
+ const initializer = unwrapExpression(declaration.getInitializer());
2449
+ if (initializer === call)
2450
+ return declaration;
2451
+ if (initializer?.isKind(SyntaxKind.YieldExpression) &&
2452
+ unwrapExpression(initializer.getExpression()) === call) {
2453
+ return declaration;
2454
+ }
2455
+ return undefined;
2456
+ }
2457
+ function initializerProperty(call) {
2458
+ const property = call.getFirstAncestorByKind(SyntaxKind.PropertyAssignment);
2459
+ if (!property)
2460
+ return undefined;
2461
+ const initializer = unwrapExpression(property.getInitializer());
2462
+ return initializer === call ? property : undefined;
2463
+ }
2464
+ function templateParameterNames(template) {
2465
+ if (!template.isKind(SyntaxKind.ArrowFunction) &&
2466
+ !template.isKind(SyntaxKind.FunctionExpression)) {
2467
+ return new Set();
2468
+ }
2469
+ const parameter = template.getParameters()[0]?.getNameNode();
2470
+ return new Set(parameter ? getBindingNames(parameter) : []);
2471
+ }
2472
+ function isBindingName(identifier) {
2473
+ const parent = identifier.getParent();
2474
+ return (parent?.isKind(SyntaxKind.BindingElement) === true ||
2475
+ parent?.isKind(SyntaxKind.Parameter) === true ||
2476
+ parent?.isKind(SyntaxKind.VariableDeclaration) === true);
2477
+ }
2478
+ function unwrapExpression(node) {
2479
+ let current = node;
2480
+ while (current && Node.isParenthesizedExpression(current)) {
2481
+ current = current.getExpression();
2482
+ }
2483
+ return current;
2484
+ }
2485
+ function addPrimitiveNode(builder, call, primitive, ownerId) {
2486
+ const name = getStringArgument(call, 0) ?? primitive;
2487
+ const usage = primitiveUsageName(call);
2488
+ return addNode(builder, {
2489
+ id: `primitive:${call.getSourceFile().getFilePath()}:${primitive}:${call.getStartLineNumber()}`,
2490
+ kind: 'primitive',
2491
+ label: `${primitive}:${name}`,
2492
+ filePath: call.getSourceFile().getFilePath(),
2493
+ line: call.getStartLineNumber(),
2494
+ details: {
2495
+ primitive,
2496
+ name,
2497
+ ownerId,
2498
+ ...(usage ? { usage } : {}),
2499
+ },
2500
+ });
2501
+ }
2502
+ function addHttpClientUsage(builder, nodeId, usage, edgeDetails = {}) {
2503
+ const node = builder.nodes.get(nodeId);
2504
+ if (!node)
2505
+ return;
2506
+ node.details = {
2507
+ ...(node.details ?? {}),
2508
+ craftHttpClient: true,
2509
+ httpEndpoints: mergeHttpEndpoints(node.details?.['httpEndpoints'], usage),
2510
+ };
2511
+ const endpointId = `http-endpoint:${usage.method}:${usage.url}`;
2512
+ const relativeFile = node.filePath
2513
+ ? relative(builder.rootDir, node.filePath).split('\\').join('/')
2514
+ : undefined;
2515
+ const endpoint = addNode(builder, {
2516
+ id: endpointId,
2517
+ kind: 'http-endpoint',
2518
+ label: `${usage.method} ${usage.url}`,
2519
+ filePath: node.filePath,
2520
+ line: usage.line,
2521
+ details: {
2522
+ method: usage.method,
2523
+ url: usage.url,
2524
+ callSites: [],
2525
+ },
2526
+ });
2527
+ const callSites = Array.isArray(endpoint.details?.['callSites'])
2528
+ ? [...endpoint.details['callSites']]
2529
+ : [];
2530
+ if (!callSites.some((site) => site.ownerId === nodeId && site.line === usage.line)) {
2531
+ callSites.push({
2532
+ ownerId: nodeId,
2533
+ line: usage.line,
2534
+ ...(relativeFile ? { filePath: relativeFile } : {}),
2535
+ });
2536
+ }
2537
+ endpoint.details = {
2538
+ ...(endpoint.details ?? {}),
2539
+ method: usage.method,
2540
+ url: usage.url,
2541
+ callSites,
2542
+ };
2543
+ addEdge(builder, nodeId, endpoint.id, 'calls', 'ast', {
2544
+ http: true,
2545
+ method: usage.method,
2546
+ url: usage.url,
2547
+ line: usage.line,
2548
+ ...edgeDetails,
2549
+ });
2550
+ }
2551
+ /**
2552
+ * Returns the resource factory whose `loader` property contains a call.
2553
+ * Keeping this fact on the edge lets architecture rules distinguish a real
2554
+ * loader dependency from an HTTP call made by params or an insertion.
2555
+ */
2556
+ function resourceLoaderFactory(call) {
2557
+ let current = call;
2558
+ while (current) {
2559
+ if (Node.isPropertyAssignment(current) && current.getName() === 'loader') {
2560
+ let parent = current.getParent();
2561
+ while (parent) {
2562
+ if (Node.isCallExpression(parent)) {
2563
+ const primitive = primitiveFactoryName(parent);
2564
+ if (primitive === 'query' ||
2565
+ primitive === 'mutation' ||
2566
+ primitive === 'queryEffect' ||
2567
+ primitive === 'mutationEffect') {
2568
+ return parent;
2569
+ }
2570
+ }
2571
+ parent = parent.getParent();
2572
+ }
2573
+ }
2574
+ current = current.getParent();
2575
+ }
2576
+ return undefined;
2577
+ }
2578
+ function addServerFunctionUsage(builder, call, aggregateOwnerId) {
2579
+ const family = serverFunctionFamilyForCall(builder, call);
2580
+ if (!family)
2581
+ return;
2582
+ const owner = ownerNodeForCall(builder, call, aggregateOwnerId);
2583
+ addEdge(builder, owner.id, family.id, 'calls', 'type', {
2584
+ serverFunction: true,
2585
+ ...(resourceLoaderFactory(call) ? { resourceRole: 'loader' } : {}),
2586
+ line: call.getStartLineNumber(),
2587
+ });
2588
+ }
2589
+ function serverFunctionFamilyForCall(builder, call) {
2590
+ const identifier = rootIdentifier(call.getExpression());
2591
+ const symbol = identifier?.getSymbol();
2592
+ const resolved = symbol?.getAliasedSymbol() ?? symbol;
2593
+ for (const declaration of resolved?.getDeclarations() ?? []) {
2594
+ const filePath = declaration.getSourceFile().getFilePath();
2595
+ if (!filePath.endsWith('.fn-client.ts'))
2596
+ continue;
2597
+ const familyPath = filePath.slice(0, -'.fn-client.ts'.length);
2598
+ return builder.nodes.get(`server-function-family:${familyPath}`);
2599
+ }
2600
+ return undefined;
2601
+ }
2602
+ function addCraftUniqueUsage(builder, call) {
2603
+ const sourceFile = call.getSourceFile();
2604
+ const filePath = relative(builder.rootDir, sourceFile.getFilePath())
2605
+ .split('\\')
2606
+ .join('/');
2607
+ const line = call.getStartLineNumber();
2608
+ const ownerId = findCraftUniqueOwnerId(builder, call);
2609
+ const canonicalized = canonicalizeStaticValue(call.getArguments()[0]);
2610
+ const id = canonicalized.static
2611
+ ? `unique:${createHash('sha256').update(canonicalized.canonical).digest('hex').slice(0, 16)}`
2612
+ : `unique:non-static:${sourceFile.getFilePath()}:${line}`;
2613
+ const label = canonicalized.static
2614
+ ? canonicalized.canonical
2615
+ : 'craftUnique(non-static)';
2616
+ const node = addNode(builder, {
2617
+ id,
2618
+ kind: 'unique',
2619
+ label,
2620
+ filePath: sourceFile.getFilePath(),
2621
+ line,
2622
+ details: {
2623
+ static: canonicalized.static,
2624
+ ...(canonicalized.static ? { canonical: canonicalized.canonical } : {}),
2625
+ callSites: [],
2626
+ },
2627
+ });
2628
+ const callSites = Array.isArray(node.details?.['callSites'])
2629
+ ? [...node.details['callSites']]
2630
+ : [];
2631
+ if (!callSites.some((site) => site.filePath === filePath &&
2632
+ site.line === line &&
2633
+ site.ownerId === ownerId)) {
2634
+ callSites.push({ filePath, line, ...(ownerId ? { ownerId } : {}) });
2635
+ }
2636
+ node.details = {
2637
+ ...(node.details ?? {}),
2638
+ static: canonicalized.static,
2639
+ ...(canonicalized.static ? { canonical: canonicalized.canonical } : {}),
2640
+ callSites,
2641
+ };
2642
+ if (ownerId) {
2643
+ addEdge(builder, ownerId, node.id, 'calls', 'ast', {
2644
+ unique: true,
2645
+ line,
2646
+ });
2647
+ }
2648
+ }
2649
+ function findCraftUniqueOwnerId(builder, call) {
2650
+ const enclosing = nearestPrimitiveFactory(call);
2651
+ const primitive = enclosing && primitiveFactoryName(enclosing);
2652
+ if (enclosing && primitive) {
2653
+ const id = `primitive:${enclosing.getSourceFile().getFilePath()}:${primitive}:${enclosing.getStartLineNumber()}`;
2654
+ if (builder.nodes.has(id))
2655
+ return id;
2656
+ }
2657
+ let current = call.getParent();
2658
+ while (current) {
2659
+ if (Node.isCallExpression(current)) {
2660
+ const service = builder.services.find((item) => item.call === current);
2661
+ if (service)
2662
+ return service.node.id;
2663
+ const component = builder.components.find((item) => item.call === current);
2664
+ if (component)
2665
+ return component.node.id;
2666
+ }
2667
+ current = current.getParent();
2668
+ }
2669
+ return undefined;
2670
+ }
2671
+ function canonicalizeStaticValue(node) {
2672
+ const value = staticLiteralValue(node);
2673
+ if (value === undefined)
2674
+ return { static: false };
2675
+ return { static: true, canonical: JSON.stringify(sortKeysDeep(value)) };
2676
+ }
2677
+ function staticLiteralValue(node) {
2678
+ const current = unwrapStaticExpression(node);
2679
+ if (!current)
2680
+ return undefined;
2681
+ if (Node.isStringLiteral(current) ||
2682
+ Node.isNoSubstitutionTemplateLiteral(current)) {
2683
+ return current.getLiteralValue();
2684
+ }
2685
+ if (Node.isNumericLiteral(current)) {
2686
+ return Number(current.getLiteralValue());
2687
+ }
2688
+ if (current.getKind() === SyntaxKind.TrueKeyword)
2689
+ return true;
2690
+ if (current.getKind() === SyntaxKind.FalseKeyword)
2691
+ return false;
2692
+ if (current.getKind() === SyntaxKind.NullKeyword)
2693
+ return null;
2694
+ if (Node.isPrefixUnaryExpression(current)) {
2695
+ const operand = staticLiteralValue(current.getOperand());
2696
+ if (typeof operand !== 'number')
2697
+ return undefined;
2698
+ if (current.getOperatorToken() === SyntaxKind.MinusToken)
2699
+ return -operand;
2700
+ if (current.getOperatorToken() === SyntaxKind.PlusToken)
2701
+ return operand;
2702
+ return undefined;
2703
+ }
2704
+ if (Node.isArrayLiteralExpression(current)) {
2705
+ const items = [];
2706
+ for (const element of current.getElements()) {
2707
+ if (Node.isSpreadElement(element))
2708
+ return undefined;
2709
+ const value = staticLiteralValue(element);
2710
+ if (value === undefined)
2711
+ return undefined;
2712
+ items.push(value);
2713
+ }
2714
+ return items;
2715
+ }
2716
+ if (Node.isObjectLiteralExpression(current)) {
2717
+ const record = {};
2718
+ for (const property of current.getProperties()) {
2719
+ if (!Node.isPropertyAssignment(property))
2720
+ return undefined;
2721
+ const key = staticPropertyName(property);
2722
+ if (key === undefined)
2723
+ return undefined;
2724
+ const value = staticLiteralValue(property.getInitializer());
2725
+ if (value === undefined)
2726
+ return undefined;
2727
+ record[key] = value;
2728
+ }
2729
+ return record;
2730
+ }
2731
+ return undefined;
2732
+ }
2733
+ function staticPropertyName(property) {
2734
+ const nameNode = property.getNameNode();
2735
+ if (Node.isIdentifier(nameNode))
2736
+ return nameNode.getText();
2737
+ if (Node.isStringLiteral(nameNode) ||
2738
+ Node.isNoSubstitutionTemplateLiteral(nameNode)) {
2739
+ return nameNode.getLiteralValue();
2740
+ }
2741
+ if (Node.isNumericLiteral(nameNode))
2742
+ return String(nameNode.getLiteralValue());
2743
+ if (Node.isComputedPropertyName(nameNode)) {
2744
+ const value = staticLiteralValue(nameNode.getExpression());
2745
+ if (typeof value === 'string' || typeof value === 'number')
2746
+ return String(value);
2747
+ }
2748
+ return undefined;
2749
+ }
2750
+ function sortKeysDeep(value) {
2751
+ if (Array.isArray(value))
2752
+ return value.map(sortKeysDeep);
2753
+ if (value && typeof value === 'object') {
2754
+ const record = value;
2755
+ const sorted = {};
2756
+ for (const key of Object.keys(record).sort()) {
2757
+ sorted[key] = sortKeysDeep(record[key]);
2758
+ }
2759
+ return sorted;
2760
+ }
2761
+ return value;
2762
+ }
2763
+ function unwrapStaticExpression(node) {
2764
+ let current = node;
2765
+ while (current) {
2766
+ if (Node.isParenthesizedExpression(current)) {
2767
+ current = current.getExpression();
2768
+ continue;
2769
+ }
2770
+ if (Node.isAsExpression(current) || Node.isSatisfiesExpression(current)) {
2771
+ current = current.getExpression();
2772
+ continue;
2773
+ }
2774
+ if (Node.isTypeAssertion(current)) {
2775
+ current = current.getExpression();
2776
+ continue;
2777
+ }
2778
+ break;
2779
+ }
2780
+ return current;
2781
+ }
2782
+ function addTemporalUsage(builder, nodeId, usage) {
2783
+ const node = builder.nodes.get(nodeId);
2784
+ if (!node)
2785
+ return;
2786
+ const previous = Array.isArray(node.details?.['temporalOperations'])
2787
+ ? node.details['temporalOperations'].filter(isTemporalOperation)
2788
+ : [];
2789
+ if (!previous.some((operation) => operation.operation === usage.operation &&
2790
+ operation.delay === usage.delay &&
2791
+ operation.line === usage.line)) {
2792
+ previous.push(usage);
2793
+ }
2794
+ node.details = {
2795
+ ...(node.details ?? {}),
2796
+ temporal: true,
2797
+ temporalOperations: previous.sort((left, right) => left.line - right.line),
2798
+ };
2799
+ }
2800
+ function mergeHttpEndpoints(previous, next) {
2801
+ const endpoints = Array.isArray(previous)
2802
+ ? previous.filter(isHttpEndpoint)
2803
+ : [];
2804
+ if (!endpoints.some((endpoint) => endpoint.method === next.method &&
2805
+ endpoint.url === next.url &&
2806
+ endpoint.line === next.line)) {
2807
+ endpoints.push(next);
2808
+ }
2809
+ return endpoints.sort((left, right) => left.line - right.line);
2810
+ }
2811
+ function isHttpEndpoint(value) {
2812
+ if (!value || typeof value !== 'object')
2813
+ return false;
2814
+ const endpoint = value;
2815
+ return (typeof endpoint['method'] === 'string' &&
2816
+ typeof endpoint['url'] === 'string' &&
2817
+ typeof endpoint['line'] === 'number');
2818
+ }
2819
+ function isTemporalOperation(value) {
2820
+ if (!value || typeof value !== 'object')
2821
+ return false;
2822
+ const operation = value;
2823
+ return (typeof operation['operation'] === 'string' &&
2824
+ typeof operation['line'] === 'number' &&
2825
+ (operation['delay'] === undefined || typeof operation['delay'] === 'string'));
2826
+ }
2827
+ function findCraftTemporalUsage(call) {
2828
+ const expression = call.getExpression();
2829
+ const root = rootIdentifier(expression)?.getText();
2830
+ const methodName = Node.isPropertyAccessExpression(expression)
2831
+ ? expression.getName()
2832
+ : undefined;
2833
+ const operation = (root && CRAFT_TEMPORAL_FUNCTIONS.has(root) && root) ||
2834
+ (methodName &&
2835
+ CRAFT_TEMPORAL_RUNTIME_METHODS.has(methodName) &&
2836
+ /(?:temporal|scheduler|clock|timer)/i.test(root ?? '')
2837
+ ? methodName
2838
+ : undefined);
2839
+ if (!operation)
2840
+ return undefined;
2841
+ const delayArgument = operation === 'setTimeout' || operation === 'setInterval'
2842
+ ? call.getArguments()[1]
2843
+ : operation === 'craftSleep' || operation === 'sleep'
2844
+ ? call.getArguments()[0]
2845
+ : undefined;
2846
+ return {
2847
+ operation,
2848
+ ...(delayArgument ? { delay: delayArgument.getText() } : {}),
2849
+ line: call.getStartLineNumber(),
2850
+ };
2851
+ }
2852
+ function findCraftHttpClientUsage(call) {
2853
+ const expression = call.getExpression();
2854
+ if (!Node.isPropertyAccessExpression(expression))
2855
+ return undefined;
2856
+ const root = rootIdentifier(expression)?.getText();
2857
+ if (root !== 'CraftHttpClient' && root !== 'craftHttpClient')
2858
+ return undefined;
2859
+ const methodName = expression.getName();
2860
+ if (!CRAFT_HTTP_CLIENT_METHODS.has(methodName))
2861
+ return undefined;
2862
+ const config = getHttpClientConfig(call);
2863
+ const url = getStaticExpressionText(config
2864
+ ?.getProperty('url')
2865
+ ?.asKind(SyntaxKind.PropertyAssignment)
2866
+ ?.getInitializer()) ?? getStringArgument(call, 0);
2867
+ if (!url)
2868
+ return undefined;
2869
+ const configuredMethod = getStaticExpressionText(config
2870
+ ?.getProperty('method')
2871
+ ?.asKind(SyntaxKind.PropertyAssignment)
2872
+ ?.getInitializer());
2873
+ return {
2874
+ method: (configuredMethod ?? methodName).toUpperCase(),
2875
+ url,
2876
+ line: call.getStartLineNumber(),
2877
+ };
2878
+ }
2879
+ function getHttpClientConfig(call) {
2880
+ const firstArgument = call.getArguments()[0];
2881
+ const directConfig = firstArgument?.asKind(SyntaxKind.ObjectLiteralExpression);
2882
+ if (directConfig)
2883
+ return directConfig;
2884
+ if (firstArgument?.isKind(SyntaxKind.ArrowFunction) ||
2885
+ firstArgument?.isKind(SyntaxKind.FunctionExpression)) {
2886
+ let body = firstArgument.getBody();
2887
+ if (Node.isParenthesizedExpression(body))
2888
+ body = body.getExpression();
2889
+ const returnedConfig = body.asKind(SyntaxKind.ObjectLiteralExpression);
2890
+ if (returnedConfig)
2891
+ return returnedConfig;
2892
+ return body
2893
+ .getDescendantsOfKind(SyntaxKind.ReturnStatement)
2894
+ .map((statement) => statement.getExpression()?.asKind(SyntaxKind.ObjectLiteralExpression))
2895
+ .find((value) => value !== undefined);
2896
+ }
2897
+ return undefined;
2898
+ }
2899
+ function getStaticExpressionText(node) {
2900
+ if (!node)
2901
+ return undefined;
2902
+ if (Node.isStringLiteral(node))
2903
+ return node.getLiteralValue();
2904
+ if (Node.isNoSubstitutionTemplateLiteral(node))
2905
+ return node.getLiteralValue();
2906
+ const text = node.getText().trim();
2907
+ return text.length > 0 ? text : undefined;
2908
+ }
2909
+ function primitiveUsageName(call) {
2910
+ const property = initializerProperty(call);
2911
+ if (property)
2912
+ return property.getName();
2913
+ const declaration = initializerDeclaration(call);
2914
+ const declarationName = declaration?.getNameNode();
2915
+ if (declarationName && Node.isIdentifier(declarationName)) {
2916
+ return declarationName.getText();
2917
+ }
2918
+ const callableProperty = call.getAncestors().find((ancestor) => {
2919
+ if (!Node.isPropertyAssignment(ancestor))
2920
+ return false;
2921
+ const initializer = ancestor.getInitializer();
2922
+ return Boolean(initializer?.isKind(SyntaxKind.ArrowFunction) ||
2923
+ initializer?.isKind(SyntaxKind.FunctionExpression));
2924
+ });
2925
+ if (callableProperty && Node.isPropertyAssignment(callableProperty)) {
2926
+ return callableProperty.getName();
2927
+ }
2928
+ return undefined;
2929
+ }
2930
+ function addServiceDependency(builder, aggregateOwnerId, service, call) {
2931
+ const ownerPrimitive = nearestPrimitiveFactory(call);
2932
+ const ownerPrimitiveName = ownerPrimitive && primitiveFactoryName(ownerPrimitive);
2933
+ const ownerNode = ownerPrimitive && ownerPrimitiveName
2934
+ ? addPrimitiveNode(builder, ownerPrimitive, ownerPrimitiveName, aggregateOwnerId)
2935
+ : undefined;
2936
+ const dependencyOwnerId = ownerNode?.id ?? aggregateOwnerId;
2937
+ const memberAccess = call
2938
+ .getExpression()
2939
+ .asKind(SyntaxKind.PropertyAccessExpression);
2940
+ const memberPath = memberAccess
2941
+ ? propertyAccessChain(memberAccess)?.slice(1).join('.')
2942
+ : undefined;
2943
+ if (memberAccess && memberPath) {
2944
+ const memberNode = addNode(builder, {
2945
+ id: `property:${service.node.id}:${memberPath}`,
2946
+ kind: 'property',
2947
+ label: `${service.node.label}.${memberPath}`,
2948
+ filePath: service.node.filePath,
2949
+ line: service.node.line,
2950
+ details: {
2951
+ member: memberPath,
2952
+ access: 'call',
2953
+ usedAt: memberAccess.getSourceFile().getFilePath(),
2954
+ usedAtLine: memberAccess.getStartLineNumber(),
2955
+ type: memberAccess.getType().getText(memberAccess),
2956
+ },
2957
+ });
2958
+ addEdge(builder, service.node.id, memberNode.id, 'contains', 'type', {
2959
+ member: memberPath,
2960
+ });
2961
+ addEdge(builder, dependencyOwnerId, service.node.id, 'depends-on', 'type', {
2962
+ member: memberPath,
2963
+ access: 'call',
2964
+ ...(resourceLoaderFactory(call) ? { resourceRole: 'loader' } : {}),
2965
+ });
2966
+ addEdge(builder, dependencyOwnerId, memberNode.id, 'depends-on', 'type', {
2967
+ member: memberPath,
2968
+ access: 'call',
2969
+ ...(resourceLoaderFactory(call) ? { resourceRole: 'loader' } : {}),
2970
+ });
2971
+ return;
2972
+ }
2973
+ addEdge(builder, dependencyOwnerId, service.node.id, 'depends-on', 'type', {
2974
+ ...(resourceLoaderFactory(call) ? { resourceRole: 'loader' } : {}),
2975
+ });
2976
+ }
2977
+ function nearestPrimitiveFactory(node) {
2978
+ let current = node.getParent();
2979
+ while (current) {
2980
+ if (Node.isCallExpression(current) && isPrimitiveFactory(current))
2981
+ return current;
2982
+ current = current.getParent();
2983
+ }
2984
+ return undefined;
2985
+ }
2986
+ function isPrimitiveFactory(call) {
2987
+ return primitiveFactoryName(call) !== undefined;
2988
+ }
2989
+ function primitiveFactoryName(call) {
2990
+ const name = call.getExpression().getText();
2991
+ if (!PRIMITIVES.has(name))
2992
+ return undefined;
2993
+ if (name === 'craftComputed' ||
2994
+ name === 'craftEffect' ||
2995
+ name === 'craftMethod' ||
2996
+ name === 'insertSelect') {
2997
+ return name;
2998
+ }
2999
+ return call.getArguments().length > 0 ? name : undefined;
3000
+ }
3001
+ function primitiveName(call) {
3002
+ const name = call.getExpression().getText();
3003
+ return PRIMITIVES.has(name) ? name : undefined;
3004
+ }
3005
+ function getServiceHelperOutputType(type) {
3006
+ const signature = type?.getCallSignatures()[0];
3007
+ const returnType = signature?.getReturnType();
3008
+ const typeArguments = returnType?.getTypeArguments();
3009
+ return typeArguments && typeArguments.length >= 2
3010
+ ? typeArguments[1]
3011
+ : undefined;
3012
+ }
3013
+ function findServiceForCall(builder, call) {
3014
+ const expression = call.getExpression();
3015
+ const identifier = rootIdentifier(expression);
3016
+ const helperName = identifier?.getText() ?? expression.getText();
3017
+ const symbol = identifier?.getSymbol();
3018
+ const key = symbolKey(symbol);
3019
+ const bySymbol = key ? builder.serviceByHelperKey.get(key) : undefined;
3020
+ if (bySymbol)
3021
+ return bySymbol;
3022
+ const byName = builder.servicesByHelperName.get(helperName) ?? [];
3023
+ return byName.length === 1 ? byName[0] : undefined;
3024
+ }
3025
+ function rootIdentifier(node) {
3026
+ let current = node;
3027
+ while (Node.isPropertyAccessExpression(current)) {
3028
+ current = current.getExpression();
3029
+ }
3030
+ return Node.isIdentifier(current) ? current : undefined;
3031
+ }
3032
+ function findComponentForCall(builder, call) {
3033
+ const expression = call.getExpression();
3034
+ if (!Node.isIdentifier(expression))
3035
+ return undefined;
3036
+ const key = symbolKey(expression.getSymbol());
3037
+ if (key)
3038
+ return builder.componentByVariableKey.get(key);
3039
+ const byName = builder.componentsByVariableName.get(expression.getText()) ?? [];
3040
+ return byName.length === 1 ? byName[0] : undefined;
3041
+ }
3042
+ function findComponentForExpression(builder, node) {
3043
+ const expression = unwrapExpression(node);
3044
+ if (!expression || !Node.isIdentifier(expression))
3045
+ return undefined;
3046
+ const key = symbolKey(expression.getSymbol());
3047
+ if (key)
3048
+ return builder.componentByVariableKey.get(key);
3049
+ const byName = builder.componentsByVariableName.get(expression.getText()) ?? [];
3050
+ return byName.length === 1 ? byName[0] : undefined;
3051
+ }
3052
+ function findComponentBoundService(component, call) {
3053
+ const identifier = rootIdentifier(call.getExpression());
3054
+ return identifier ? component.bindings.get(identifier.getText()) : undefined;
3055
+ }
3056
+ function findBindingElement(node, helperName) {
3057
+ if (!node || !Node.isObjectBindingPattern(node))
3058
+ return undefined;
3059
+ return node.getElements().find((element) => {
3060
+ if (!Node.isBindingElement(element))
3061
+ return false;
3062
+ return (element.getNameNode().getText() === helperName ||
3063
+ element.getPropertyNameNode()?.getText() === helperName);
3064
+ });
3065
+ }
3066
+ function findBindingNameNode(node, name) {
3067
+ if (!node)
3068
+ return undefined;
3069
+ if (Node.isIdentifier(node))
3070
+ return node.getText() === name ? node : undefined;
3071
+ if (!Node.isObjectBindingPattern(node) && !Node.isArrayBindingPattern(node)) {
3072
+ return undefined;
3073
+ }
3074
+ for (const element of node.getElements()) {
3075
+ if (!Node.isBindingElement(element))
3076
+ continue;
3077
+ const match = findBindingNameNode(element.getNameNode(), name);
3078
+ if (match)
3079
+ return match;
3080
+ }
3081
+ return undefined;
3082
+ }
3083
+ function symbolKey(symbol) {
3084
+ if (!symbol)
3085
+ return undefined;
3086
+ const resolved = symbol.getAliasedSymbol() ?? symbol;
3087
+ const declaration = resolved.getDeclarations()[0] ?? symbol.getDeclarations()[0];
3088
+ return declaration
3089
+ ? `${declaration.getSourceFile().getFilePath()}:${declaration.getStart()}`
3090
+ : `symbol:${resolved.getFullyQualifiedName()}`;
3091
+ }
3092
+ function propertyAccessChain(access) {
3093
+ const parts = [access.getName()];
3094
+ let expression = access.getExpression();
3095
+ while (Node.isPropertyAccessExpression(expression)) {
3096
+ parts.unshift(expression.getName());
3097
+ expression = expression.getExpression();
3098
+ }
3099
+ return Node.isIdentifier(expression)
3100
+ ? [expression.getText(), ...parts]
3101
+ : undefined;
3102
+ }
3103
+ function findDynamicImportSpecifiers(node) {
3104
+ return node
3105
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
3106
+ .filter((call) => call.getExpression().getText() === 'import')
3107
+ .map((call) => getStringArgument(call, 0))
3108
+ .filter((value) => value !== undefined);
3109
+ }
3110
+ function findDynamicImportExportNames(node, specifier) {
3111
+ const importCall = node
3112
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
3113
+ .find((call) => call.getExpression().getText() === 'import' &&
3114
+ getStringArgument(call, 0) === specifier);
3115
+ if (!importCall)
3116
+ return undefined;
3117
+ let current = importCall;
3118
+ for (let depth = 0; depth < 8; depth += 1) {
3119
+ const parent = current.getParent();
3120
+ if (!parent)
3121
+ return undefined;
3122
+ if (Node.isCallExpression(parent) &&
3123
+ parent.getExpression().getText() === 'withRetry') {
3124
+ current = parent;
3125
+ continue;
3126
+ }
3127
+ if (Node.isPropertyAccessExpression(parent) &&
3128
+ parent.getName() === 'then') {
3129
+ const thenCall = parent.getParent();
3130
+ return thenCall && Node.isCallExpression(thenCall)
3131
+ ? extractDynamicImportExportNames(thenCall)
3132
+ : undefined;
3133
+ }
3134
+ return undefined;
3135
+ }
3136
+ return undefined;
3137
+ }
3138
+ function extractDynamicImportExportNames(call) {
3139
+ const callback = call.getArguments()[0];
3140
+ if (!callback || (!Node.isArrowFunction(callback) && !Node.isFunctionExpression(callback))) {
3141
+ return undefined;
3142
+ }
3143
+ const parameter = callback.getParameters()[0];
3144
+ const body = callback.getBody();
3145
+ if (!parameter || !body || !Node.isExpression(body))
3146
+ return undefined;
3147
+ const bindingPattern = parameter.getNameNode();
3148
+ if (Node.isObjectBindingPattern(bindingPattern) && Node.isIdentifier(body)) {
3149
+ const binding = bindingPattern.getElements().find((element) => Node.isBindingElement(element) &&
3150
+ element.getNameNode().getText() === body.getText());
3151
+ if (binding && Node.isBindingElement(binding)) {
3152
+ return [binding.getPropertyNameNode()?.getText() ?? body.getText()];
3153
+ }
3154
+ }
3155
+ if (Node.isPropertyAccessExpression(body)) {
3156
+ const root = rootIdentifier(body.getExpression());
3157
+ if (root && root.getText() === parameter.getName()) {
3158
+ return [body.getName()];
3159
+ }
3160
+ }
3161
+ return undefined;
3162
+ }
3163
+ function componentExportNames(component) {
3164
+ const declaration = component.call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
3165
+ if (!declaration)
3166
+ return [];
3167
+ return [...component.call.getSourceFile().getExportedDeclarations()]
3168
+ .filter(([, declarations]) => declarations.some((candidate) => candidate.getStart() === declaration.getStart()))
3169
+ .map(([name]) => name);
3170
+ }
3171
+ function resolveImportedSource(sourceFile, specifier, project) {
3172
+ if (!specifier.startsWith('.'))
3173
+ return undefined;
3174
+ const base = resolve(sourceFile.getDirectoryPath(), specifier);
3175
+ const candidates = [
3176
+ base,
3177
+ `${base}.ts`,
3178
+ `${base}.tsx`,
3179
+ join(base, 'index.ts'),
3180
+ ];
3181
+ return candidates
3182
+ .map((candidate) => project.getSourceFile(candidate))
3183
+ .find((candidate) => candidate !== undefined);
3184
+ }
3185
+ function addNode(builder, node) {
3186
+ const existing = builder.nodes.get(node.id);
3187
+ if (existing) {
3188
+ if (existing.kind !== node.kind || existing.label !== node.label) {
3189
+ throw new Error(`Dependency graph node identity collision for "${node.id}": ${existing.kind}/${existing.label} versus ${node.kind}/${node.label}.`);
3190
+ }
3191
+ return existing;
3192
+ }
3193
+ builder.nodes.set(node.id, node);
3194
+ return node;
3195
+ }
3196
+ function mergeCollectorContribution(builder, collectorName, contribution) {
3197
+ for (const node of contribution.nodes ?? []) {
3198
+ if (!node.id || !node.kind || !node.label) {
3199
+ throw new Error(`Dependency graph collector "${collectorName}" returned a node without id, kind, or label.`);
3200
+ }
3201
+ const existing = builder.nodes.get(node.id);
3202
+ if (existing &&
3203
+ (existing.kind !== node.kind || existing.label !== node.label)) {
3204
+ throw new Error(`Dependency graph collector "${collectorName}" redefined node "${node.id}" with a different identity.`);
3205
+ }
3206
+ addNode(builder, node);
3207
+ }
3208
+ for (const edge of contribution.edges ?? []) {
3209
+ if (!edge.from || !edge.to || !edge.kind) {
3210
+ throw new Error(`Dependency graph collector "${collectorName}" returned a relation without from, to, or kind.`);
3211
+ }
3212
+ if (!builder.nodes.has(edge.from) || !builder.nodes.has(edge.to)) {
3213
+ throw new Error(`Dependency graph collector "${collectorName}" returned relation "${edge.from} -[${edge.kind}]-> ${edge.to}" with an unknown endpoint.`);
3214
+ }
3215
+ addEdge(builder, edge.from, edge.to, edge.kind, edge.evidence, edge.details, edge.proof);
3216
+ }
3217
+ for (const diagnostic of contribution.diagnostics ?? []) {
3218
+ builder.diagnostics.push({
3219
+ ...diagnostic,
3220
+ code: `${collectorName}/${diagnostic.code}`,
3221
+ });
3222
+ }
3223
+ }
3224
+ // ---------------------------------------------------------------------------
3225
+ // Task 3.3 — fold the Effect-service contribution into the graph.
3226
+ //
3227
+ // The owner of an edge is the component or craft service whose factory call
3228
+ // encloses the `effectService(...)` call. A call that sits outside any known
3229
+ // consumer is skipped: attaching it to an arbitrary node would draw an edge
3230
+ // that is not true.
3231
+ // ---------------------------------------------------------------------------
3232
+ function collectEffectGraph(builder, sourceFiles) {
3233
+ const services = collectEffectServices(sourceFiles);
3234
+ const dataFlow = collectDataFlowGraph(sourceFiles);
3235
+ for (const node of dataFlow.nodes)
3236
+ addNode(builder, node);
3237
+ for (const edge of dataFlow.edges) {
3238
+ addEdge(builder, edge.from, edge.to, edge.kind, edge.evidence, edge.details, edge.proof);
3239
+ }
3240
+ if (services.size === 0)
3241
+ return;
3242
+ const operationOwners = collectEffectOperationOwners(sourceFiles, services);
3243
+ for (const owner of operationOwners.values())
3244
+ addNode(builder, owner.node);
3245
+ const owners = [
3246
+ ...[...builder.components, ...builder.services].map((owner) => ({
3247
+ start: owner.call.getStart(),
3248
+ end: owner.call.getEnd(),
3249
+ id: owner.node.id,
3250
+ file: owner.call.getSourceFile().getFilePath(),
3251
+ })),
3252
+ ...[...builder.nodes.values()]
3253
+ .filter((node) => node.kind === 'server-function-server')
3254
+ .map((node) => ({
3255
+ start: 0,
3256
+ end: Number.MAX_SAFE_INTEGER,
3257
+ id: node.id,
3258
+ file: node.filePath ?? '',
3259
+ })),
3260
+ ];
3261
+ const ownerIdOf = (node) => {
3262
+ const file = node.getSourceFile().getFilePath();
3263
+ const start = node.getStart();
3264
+ let best;
3265
+ for (const owner of owners) {
3266
+ if (owner.file !== file)
3267
+ continue;
3268
+ if (owner.start > start || owner.end < start)
3269
+ continue;
3270
+ // Innermost wins, so a service nested in a component is credited.
3271
+ if (!best || owner.end - owner.start < best.end - best.start) {
3272
+ best = owner;
3273
+ }
3274
+ }
3275
+ const enclosingPrimitive = nearestPrimitiveFactory(node);
3276
+ const primitive = enclosingPrimitive && primitiveFactoryName(enclosingPrimitive);
3277
+ if (enclosingPrimitive && primitive && best) {
3278
+ return addPrimitiveNode(builder, enclosingPrimitive, primitive, best.id)
3279
+ .id;
3280
+ }
3281
+ if (best)
3282
+ return best.id;
3283
+ const operation = [...operationOwners.values()]
3284
+ .filter((candidate) => candidate.filePath === file &&
3285
+ candidate.start <= start &&
3286
+ candidate.end >= start)
3287
+ .sort((left, right) => left.end - left.start - (right.end - right.start))[0];
3288
+ return operation?.node.id;
3289
+ };
3290
+ const contribution = collectEffectServiceUsage(sourceFiles, services, ownerIdOf, true);
3291
+ for (const node of contribution.nodes) {
3292
+ addNode(builder, node);
3293
+ }
3294
+ for (const edge of contribution.edges) {
3295
+ addEdge(builder, edge.from, edge.to, edge.kind, edge.evidence, edge.details, edge.proof);
3296
+ }
3297
+ const layers = collectEffectLayers(sourceFiles, services);
3298
+ for (const node of layers.nodes)
3299
+ addNode(builder, node);
3300
+ for (const edge of layers.edges) {
3301
+ addEdge(builder, edge.from, edge.to, edge.kind, edge.evidence, edge.details, edge.proof);
3302
+ }
3303
+ }
3304
+ /**
3305
+ * Records server-function boundaries from source naming and the small public
3306
+ * API. This deliberately runs on source files, not emitted bundles: the
3307
+ * architecture check must be able to reject a bad boundary before production
3308
+ * build transforms anything.
3309
+ */
3310
+ function collectServerFunctions(builder, sourceFiles) {
3311
+ const parts = [];
3312
+ const byPath = new Map(sourceFiles.map((file) => [file.getFilePath(), file]));
3313
+ for (const sourceFile of sourceFiles) {
3314
+ const baseName = sourceFile.getBaseName();
3315
+ const suffix = serverFunctionSuffix(baseName);
3316
+ if (!suffix)
3317
+ continue;
3318
+ const family = sourceFile.getFilePath().slice(0, -suffix.length);
3319
+ const imports = serverFunctionImports(sourceFile, byPath);
3320
+ if (suffix === '.fn-contract.ts') {
3321
+ const contract = findServerFunctionContract(sourceFile);
3322
+ parts.push({
3323
+ kind: 'server-function-contract',
3324
+ sourceFile,
3325
+ family,
3326
+ id: contract?.id,
3327
+ exposure: contract?.exposure,
3328
+ importsServerOnly: imports.serverOnly,
3329
+ });
3330
+ }
3331
+ else if (suffix === '.fn-client.ts') {
3332
+ const clientContract = findClientContractFamily(sourceFile, byPath);
3333
+ parts.push({
3334
+ kind: 'server-function-client',
3335
+ sourceFile,
3336
+ family,
3337
+ id: clientContract?.id,
3338
+ contractFamily: clientContract?.family,
3339
+ clientDefinitionFile: clientContract?.definitionFile,
3340
+ usesCraftUnique: clientContract?.usesCraftUnique,
3341
+ clientIdentityStatic: clientContract?.identityStatic,
3342
+ runtimeServerImports: imports.server,
3343
+ runtimeClientImports: imports.client,
3344
+ runtimeMiddlewareImports: imports.middleware,
3345
+ });
3346
+ }
3347
+ else {
3348
+ const server = findServerFunction(sourceFile, byPath);
3349
+ parts.push({
3350
+ kind: 'server-function-server',
3351
+ sourceFile,
3352
+ family,
3353
+ id: server?.id,
3354
+ exposure: server?.exposure,
3355
+ declaresClientContext: server?.declaresClientContext,
3356
+ contractFamily: server?.contractFamily,
3357
+ runtimeClientImports: imports.client,
3358
+ runtimeClientMiddlewareImports: imports.clientMiddleware,
3359
+ middlewareUses: server?.middlewareUses,
3360
+ });
3361
+ }
3362
+ }
3363
+ const families = new Map();
3364
+ for (const part of parts) {
3365
+ const entries = families.get(part.family) ?? [];
3366
+ entries.push(part);
3367
+ families.set(part.family, entries);
3368
+ }
3369
+ for (const [family, familyParts] of families) {
3370
+ const id = familyParts.find((part) => part.id)?.id;
3371
+ const familyNode = addNode(builder, {
3372
+ id: `server-function-family:${family}`,
3373
+ kind: 'server-function-family',
3374
+ label: id ?? relative(builder.rootDir, family),
3375
+ filePath: familyParts[0]?.sourceFile.getFilePath(),
3376
+ line: familyParts[0]?.sourceFile.getLineAndColumnAtPos(0).line,
3377
+ details: {
3378
+ family: relative(builder.rootDir, family),
3379
+ ...(id === undefined ? {} : { serverFunctionId: id }),
3380
+ },
3381
+ });
3382
+ for (const part of familyParts) {
3383
+ const node = addNode(builder, {
3384
+ id: `server-function-part:${part.kind}:${part.sourceFile.getFilePath()}`,
3385
+ kind: part.kind,
3386
+ label: id ?? part.id ?? relative(builder.rootDir, family),
3387
+ filePath: part.sourceFile.getFilePath(),
3388
+ line: part.sourceFile.getLineAndColumnAtPos(0).line,
3389
+ details: {
3390
+ family: relative(builder.rootDir, family),
3391
+ ...(part.id === undefined ? {} : { serverFunctionId: part.id }),
3392
+ ...(part.exposure === undefined ? {} : { exposure: part.exposure }),
3393
+ ...(part.declaresClientContext === undefined
3394
+ ? {}
3395
+ : { declaresClientContext: part.declaresClientContext }),
3396
+ ...(part.contractFamily === undefined
3397
+ ? {}
3398
+ : {
3399
+ contractFamily: relative(builder.rootDir, part.contractFamily),
3400
+ }),
3401
+ ...(part.clientDefinitionFile === undefined
3402
+ ? {}
3403
+ : {
3404
+ clientDefinitionFile: relative(builder.rootDir, part.clientDefinitionFile),
3405
+ }),
3406
+ ...(part.usesCraftUnique === undefined
3407
+ ? {}
3408
+ : { usesCraftUnique: part.usesCraftUnique }),
3409
+ ...(part.clientIdentityStatic === undefined
3410
+ ? {}
3411
+ : { clientIdentityStatic: part.clientIdentityStatic }),
3412
+ ...(part.runtimeServerImports?.length
3413
+ ? {
3414
+ runtimeServerImports: part.runtimeServerImports.map((file) => relative(builder.rootDir, file)),
3415
+ }
3416
+ : {}),
3417
+ ...(part.runtimeClientImports?.length
3418
+ ? {
3419
+ runtimeClientImports: part.runtimeClientImports.map((file) => relative(builder.rootDir, file)),
3420
+ }
3421
+ : {}),
3422
+ ...(part.importsServerOnly?.length
3423
+ ? {
3424
+ importsServerOnly: part.importsServerOnly.map((file) => relative(builder.rootDir, file)),
3425
+ }
3426
+ : {}),
3427
+ ...(part.runtimeMiddlewareImports?.length
3428
+ ? {
3429
+ runtimeMiddlewareImports: part.runtimeMiddlewareImports.map((file) => relative(builder.rootDir, file)),
3430
+ }
3431
+ : {}),
3432
+ ...(part.runtimeClientMiddlewareImports?.length
3433
+ ? {
3434
+ runtimeClientMiddlewareImports: part.runtimeClientMiddlewareImports.map((file) => relative(builder.rootDir, file)),
3435
+ }
3436
+ : {}),
3437
+ ...(part.middlewareUses?.length
3438
+ ? { middlewareUses: [...part.middlewareUses] }
3439
+ : {}),
3440
+ },
3441
+ });
3442
+ addEdge(builder, familyNode.id, node.id, 'contains', 'ast');
3443
+ for (const imported of part.runtimeServerImports ?? []) {
3444
+ addEdge(builder, node.id, `server-function-part:server-function-server:${imported}`, 'depends-on', 'ast', {
3445
+ boundary: 'client-imports-server',
3446
+ });
3447
+ }
3448
+ for (const imported of part.runtimeClientImports ?? []) {
3449
+ addEdge(builder, node.id, `server-function-part:server-function-client:${imported}`, 'depends-on', 'ast', {
3450
+ boundary: 'server-imports-client',
3451
+ });
3452
+ }
3453
+ }
3454
+ }
3455
+ for (const sourceFile of sourceFiles) {
3456
+ if (serverFunctionSuffix(sourceFile.getBaseName()))
3457
+ continue;
3458
+ if (declaresApi(sourceFile, 'serverFunction') ||
3459
+ declaresApi(sourceFile, 'portableServerFunction'))
3460
+ continue;
3461
+ const misnamed = findServerFunction(sourceFile, byPath);
3462
+ if (!misnamed)
3463
+ continue;
3464
+ addNode(builder, {
3465
+ id: `server-function-misnamed:${sourceFile.getFilePath()}`,
3466
+ kind: 'server-function-misnamed',
3467
+ label: misnamed.id ?? relative(builder.rootDir, sourceFile.getFilePath()),
3468
+ filePath: sourceFile.getFilePath(),
3469
+ line: sourceFile.getLineAndColumnAtPos(0).line,
3470
+ details: {
3471
+ ...(misnamed.id === undefined ? {} : { serverFunctionId: misnamed.id }),
3472
+ },
3473
+ });
3474
+ }
3475
+ }
3476
+ /**
3477
+ * Remonte une chaîne d'appels `a(...).b(...).c(...)` depuis l'appel racine et
3478
+ * renvoie les identifiants passés en premier argument à `.<method>(...)`.
3479
+ */
3480
+ /**
3481
+ * Comme `chainedCallArguments`, mais pour un `.pipe(a, b, c)` : la composition
3482
+ * est variadique, et seuls les arguments nommés désignent une brique du graphe
3483
+ * — `mapContext(...)` ou `requireServerPermission(...)` n'en sont pas.
3484
+ */
3485
+ function chainedCallIdentifierArguments(call, method) {
3486
+ const names = [];
3487
+ let current = call;
3488
+ for (;;) {
3489
+ const access = current.getParentIfKind(SyntaxKind.PropertyAccessExpression);
3490
+ if (!access)
3491
+ break;
3492
+ const outer = access.getParentIfKind(SyntaxKind.CallExpression);
3493
+ if (!outer)
3494
+ break;
3495
+ if (access.getName() === method) {
3496
+ for (const argument of outer.getArguments()) {
3497
+ const identifier = argument.asKind(SyntaxKind.Identifier);
3498
+ if (identifier)
3499
+ names.push(identifier.getText());
3500
+ }
3501
+ }
3502
+ current = outer;
3503
+ }
3504
+ return names;
3505
+ }
3506
+ function chainedCallArguments(call, method) {
3507
+ const names = [];
3508
+ let current = call;
3509
+ for (;;) {
3510
+ const access = current.getParentIfKind(SyntaxKind.PropertyAccessExpression);
3511
+ if (!access)
3512
+ break;
3513
+ const outer = access.getParentIfKind(SyntaxKind.CallExpression);
3514
+ if (!outer)
3515
+ break;
3516
+ if (access.getName() === method) {
3517
+ const argument = outer.getArguments()[0]?.asKind(SyntaxKind.Identifier);
3518
+ if (argument)
3519
+ names.push(argument.getText());
3520
+ }
3521
+ current = outer;
3522
+ }
3523
+ return names;
3524
+ }
3525
+ /**
3526
+ * Vrai quand le fichier *définit* l'API plutôt que de l'utiliser — le module du
3527
+ * framework lui-même. Sans ce garde-fou, une analyse dont le programme inclut
3528
+ * les sources de `@craft-ts/core` signalerait la définition de `serverFunction`
3529
+ * comme une server function mal nommée.
3530
+ */
3531
+ function declaresApi(sourceFile, name) {
3532
+ const declaration = sourceFile.getFunction(name);
3533
+ if (declaration &&
3534
+ !declaration.hasDeclareKeyword() &&
3535
+ declaration.isExported()) {
3536
+ return true;
3537
+ }
3538
+ const variable = sourceFile.getVariableDeclaration(name);
3539
+ return (variable !== undefined &&
3540
+ variable.isExported() &&
3541
+ variable.getVariableStatement()?.hasDeclareKeyword() !== true);
3542
+ }
3543
+ /** Noms des méthodes chaînées après l'appel racine, dans l'ordre rencontré. */
3544
+ function chainedCallMethods(call) {
3545
+ const names = [];
3546
+ let current = call;
3547
+ for (;;) {
3548
+ const access = current.getParentIfKind(SyntaxKind.PropertyAccessExpression);
3549
+ if (!access)
3550
+ break;
3551
+ const outer = access.getParentIfKind(SyntaxKind.CallExpression);
3552
+ if (!outer)
3553
+ break;
3554
+ names.push(access.getName());
3555
+ current = outer;
3556
+ }
3557
+ return names;
3558
+ }
3559
+ /**
3560
+ * Clés d'objet passées à `.<method>(...)`, à travers un éventuel
3561
+ * `Schema.Struct({ ... })`. Best-effort et assumé comme tel : c'est ce qui
3562
+ * alimente le diagnostic heuristique de contexte client inutilisé.
3563
+ */
3564
+ function chainedCallObjectKeys(call, method) {
3565
+ const keys = [];
3566
+ let current = call;
3567
+ for (;;) {
3568
+ const access = current.getParentIfKind(SyntaxKind.PropertyAccessExpression);
3569
+ if (!access)
3570
+ break;
3571
+ const outer = access.getParentIfKind(SyntaxKind.CallExpression);
3572
+ if (!outer)
3573
+ break;
3574
+ if (access.getName() === method) {
3575
+ const argument = outer.getArguments()[0];
3576
+ const literals = [
3577
+ ...(argument?.asKind(SyntaxKind.ObjectLiteralExpression)
3578
+ ? [argument.asKindOrThrow(SyntaxKind.ObjectLiteralExpression)]
3579
+ : []),
3580
+ ...(argument?.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression) ?? []),
3581
+ ];
3582
+ for (const literal of literals) {
3583
+ for (const property of literal.getProperties()) {
3584
+ const name = property
3585
+ .asKind(SyntaxKind.PropertyAssignment)
3586
+ ?.getName();
3587
+ if (name)
3588
+ keys.push(name.replace(/^['"]|['"]$/g, ''));
3589
+ }
3590
+ }
3591
+ }
3592
+ current = outer;
3593
+ }
3594
+ return keys;
3595
+ }
3596
+ /**
3597
+ * Records server-function middleware declared with `craftMiddleware(...)`,
3598
+ * `portableServerMiddleware(...)`, or `effectServerMiddleware(...)`, plus
3599
+ * the `.use(...)` edges between them and towards the server functions that
3600
+ * declare them. Like the server-function boundaries above, this reads source
3601
+ * files so a bad boundary fails before any bundle transform.
3602
+ */
3603
+ function collectServerFunctionMiddlewares(builder, sourceFiles) {
3604
+ const byPath = new Map(sourceFiles.map((file) => [file.getFilePath(), file]));
3605
+ const registry = new Map();
3606
+ for (const sourceFile of sourceFiles) {
3607
+ if (declaresApi(sourceFile, 'craftMiddleware') ||
3608
+ declaresApi(sourceFile, 'portableServerMiddleware') ||
3609
+ declaresApi(sourceFile, 'effectServerMiddleware') ||
3610
+ declaresApi(sourceFile, 'serverLayer'))
3611
+ continue;
3612
+ const isMiddlewareFile = isServerMiddlewareFile(sourceFile.getBaseName());
3613
+ for (const part of findCraftMiddlewares(sourceFile)) {
3614
+ // Un `.client(...)` appartient à l'autre famille : il est modélisé par
3615
+ // `collectClientFunctionMiddlewares`, pas ici.
3616
+ if (part.terminal === 'client')
3617
+ continue;
3618
+ if (!isMiddlewareFile) {
3619
+ addNode(builder, {
3620
+ id: `server-function-middleware-misnamed:${sourceFile.getFilePath()}#${part.variableName}`,
3621
+ kind: 'server-function-middleware-misnamed',
3622
+ label: part.id ?? part.variableName,
3623
+ filePath: sourceFile.getFilePath(),
3624
+ line: part.line,
3625
+ details: {
3626
+ ...(part.id === undefined ? {} : { middlewareId: part.id }),
3627
+ middlewareName: part.variableName,
3628
+ },
3629
+ });
3630
+ continue;
3631
+ }
3632
+ registry.set(middlewareKey(sourceFile.getFilePath(), part.variableName), part);
3633
+ }
3634
+ }
3635
+ for (const part of registry.values()) {
3636
+ const clientMiddlewareImports = serverFunctionImports(part.sourceFile, byPath).clientMiddleware;
3637
+ addNode(builder, {
3638
+ id: middlewareNodeId(part.sourceFile.getFilePath(), part.variableName),
3639
+ kind: 'server-function-middleware',
3640
+ label: part.id ?? part.variableName,
3641
+ filePath: part.sourceFile.getFilePath(),
3642
+ line: part.line,
3643
+ details: {
3644
+ ...(part.id === undefined ? {} : { middlewareId: part.id }),
3645
+ middlewareName: part.variableName,
3646
+ composition: part.composition ?? 'use',
3647
+ ...(builder.middlewareCapabilities[part.id ?? part.variableName]
3648
+ ? {
3649
+ protects: builder.middlewareCapabilities[part.id ?? part.variableName],
3650
+ }
3651
+ : {}),
3652
+ ...(clientMiddlewareImports.length
3653
+ ? {
3654
+ runtimeClientMiddlewareImports: clientMiddlewareImports.map((file) => relative(builder.rootDir, file)),
3655
+ }
3656
+ : {}),
3657
+ },
3658
+ });
3659
+ }
3660
+ for (const part of registry.values()) {
3661
+ for (const used of part.uses) {
3662
+ const target = resolveMiddlewareReference(part.sourceFile, used, byPath, registry);
3663
+ if (!target)
3664
+ continue;
3665
+ addEdge(builder, middlewareNodeId(part.sourceFile.getFilePath(), part.variableName), middlewareNodeId(target.sourceFile.getFilePath(), target.variableName), 'depends-on', 'ast', { boundary: 'middleware-uses' });
3666
+ }
3667
+ }
3668
+ for (const sourceFile of sourceFiles) {
3669
+ if (!sourceFile.getBaseName().endsWith('.fn-serveur.ts'))
3670
+ continue;
3671
+ const server = findServerFunction(sourceFile, byPath);
3672
+ // Les deux compositions produisent la même arête — c'est la même
3673
+ // dépendance — mais le graphe garde de laquelle il s'agit : c'est ce qui
3674
+ // permet de vérifier qu'un exemple migré ne passe plus par `.use(...)`.
3675
+ const composed = [
3676
+ ...(server?.middlewareUses ?? []).map((used) => [used, 'use']),
3677
+ ...(server?.layerPipes ?? []).map((used) => [used, 'pipe']),
3678
+ ];
3679
+ for (const [used, composition] of composed) {
3680
+ const target = resolveMiddlewareReference(sourceFile, used, byPath, registry);
3681
+ if (!target)
3682
+ continue;
3683
+ addEdge(builder, `server-function-part:server-function-server:${sourceFile.getFilePath()}`, middlewareNodeId(target.sourceFile.getFilePath(), target.variableName), 'depends-on', 'ast', { boundary: 'middleware-uses', composition });
3684
+ }
3685
+ }
3686
+ }
3687
+ /**
3688
+ * Jumeau client de `collectServerFunctionMiddlewares` : modélise les
3689
+ * `craftMiddleware(...).client(...)`, leurs arêtes `.use(...)`, et les façades
3690
+ * `*.fn-client.ts` qui les attachent via `.pipe(craftClientMiddleware(...))`.
3691
+ *
3692
+ * La règle de frontière est l'inverse de celle du serveur : un middleware
3693
+ * client ne doit jamais être importé au runtime par un module serveur.
3694
+ */
3695
+ function collectClientFunctionMiddlewares(builder, sourceFiles) {
3696
+ const byPath = new Map(sourceFiles.map((file) => [file.getFilePath(), file]));
3697
+ const registry = new Map();
3698
+ for (const sourceFile of sourceFiles) {
3699
+ if (declaresApi(sourceFile, 'craftMiddleware') ||
3700
+ declaresApi(sourceFile, 'portableServerMiddleware') ||
3701
+ declaresApi(sourceFile, 'effectServerMiddleware') ||
3702
+ declaresApi(sourceFile, 'serverLayer'))
3703
+ continue;
3704
+ const isClientFile = declaresClientMiddleware(sourceFile.getBaseName());
3705
+ for (const part of findCraftMiddlewares(sourceFile, byPath)) {
3706
+ if (part.terminal !== 'client')
3707
+ continue;
3708
+ if (!isClientFile) {
3709
+ addNode(builder, {
3710
+ id: `client-function-middleware-misnamed:${sourceFile.getFilePath()}#${part.variableName}`,
3711
+ kind: 'client-function-middleware-misnamed',
3712
+ label: part.id ?? part.variableName,
3713
+ filePath: sourceFile.getFilePath(),
3714
+ line: part.line,
3715
+ details: {
3716
+ ...(part.id === undefined ? {} : { middlewareId: part.id }),
3717
+ middlewareName: part.variableName,
3718
+ },
3719
+ });
3720
+ continue;
3721
+ }
3722
+ registry.set(middlewareKey(sourceFile.getFilePath(), part.variableName), part);
3723
+ }
3724
+ }
3725
+ const serverReads = collectClientContextReads(sourceFiles);
3726
+ for (const part of registry.values()) {
3727
+ const unused = part.providesKeys.filter((key) => !serverReads.has(key));
3728
+ addNode(builder, {
3729
+ id: clientMiddlewareNodeId(part.sourceFile.getFilePath(), part.variableName),
3730
+ kind: 'client-function-middleware',
3731
+ label: part.id ?? part.variableName,
3732
+ filePath: part.sourceFile.getFilePath(),
3733
+ line: part.line,
3734
+ details: {
3735
+ ...(part.id === undefined ? {} : { middlewareId: part.id }),
3736
+ middlewareName: part.variableName,
3737
+ composition: part.composition ?? 'use',
3738
+ ...(builder.middlewareCapabilities[part.id ?? part.variableName]
3739
+ ? {
3740
+ protects: builder.middlewareCapabilities[part.id ?? part.variableName],
3741
+ }
3742
+ : {}),
3743
+ ...(part.providesKeys.length
3744
+ ? { providesKeys: [...part.providesKeys] }
3745
+ : {}),
3746
+ ...(part.providesKeys.length > 0 &&
3747
+ unused.length === part.providesKeys.length
3748
+ ? { unusedClientContextKeys: unused }
3749
+ : {}),
3750
+ },
3751
+ });
3752
+ }
3753
+ for (const part of registry.values()) {
3754
+ for (const used of part.uses) {
3755
+ const target = resolveMiddlewareReference(part.sourceFile, used, byPath, registry);
3756
+ if (!target)
3757
+ continue;
3758
+ addEdge(builder, clientMiddlewareNodeId(part.sourceFile.getFilePath(), part.variableName), clientMiddlewareNodeId(target.sourceFile.getFilePath(), target.variableName), 'depends-on', 'ast', { boundary: 'client-middleware-uses' });
3759
+ }
3760
+ }
3761
+ for (const sourceFile of sourceFiles) {
3762
+ if (!sourceFile.getBaseName().endsWith('.fn-client.ts'))
3763
+ continue;
3764
+ for (const used of findClientContextAttachments(sourceFile)) {
3765
+ const target = resolveMiddlewareReference(sourceFile, used, byPath, registry);
3766
+ if (!target)
3767
+ continue;
3768
+ addEdge(builder, `server-function-part:server-function-client:${sourceFile.getFilePath()}`, clientMiddlewareNodeId(target.sourceFile.getFilePath(), target.variableName), 'depends-on', 'ast', { boundary: 'client-middleware-attached' });
3769
+ }
3770
+ }
3771
+ }
3772
+ /**
3773
+ * Modélise les `craftHandshake(...)` et, pour chacun, les fichiers qui le
3774
+ * référencent — classés par côté de la frontière d'après leur suffixe.
3775
+ *
3776
+ * C'est tout ce dont la règle a besoin : un handshake est tenu quand au moins
3777
+ * un module serveur et au moins un module client le référencent. Le contrôle
3778
+ * est **exact**, puisqu'il compare des identifiants et non des noms de clés
3779
+ * devinés dans l'AST.
3780
+ */
3781
+ function collectHandshakes(builder, sourceFiles) {
3782
+ const byPath = new Map(sourceFiles.map((file) => [file.getFilePath(), file]));
3783
+ const registry = new Map();
3784
+ for (const sourceFile of sourceFiles) {
3785
+ if (declaresApi(sourceFile, 'craftHandshake'))
3786
+ continue;
3787
+ for (const declaration of sourceFile.getVariableDeclarations()) {
3788
+ const initializer = declaration.getInitializer();
3789
+ if (!initializer)
3790
+ continue;
3791
+ const call = [
3792
+ ...(Node.isCallExpression(initializer) ? [initializer] : []),
3793
+ ...initializer.getDescendantsOfKind(SyntaxKind.CallExpression),
3794
+ ].find((candidate) => candidate.getExpression().getText() === 'craftHandshake');
3795
+ if (!call)
3796
+ continue;
3797
+ const name = call
3798
+ .getArguments()[0]
3799
+ ?.asKind(SyntaxKind.StringLiteral)
3800
+ ?.getLiteralValue();
3801
+ registry.set(handshakeKey(sourceFile.getFilePath(), declaration.getName()), {
3802
+ sourceFile,
3803
+ variableName: declaration.getName(),
3804
+ ...(name === undefined ? {} : { name }),
3805
+ line: declaration.getStartLineNumber(),
3806
+ });
3807
+ }
3808
+ }
3809
+ const sides = new Map();
3810
+ for (const part of registry.values()) {
3811
+ sides.set(part, { server: [], client: [] });
3812
+ }
3813
+ for (const sourceFile of sourceFiles) {
3814
+ const side = handshakeSide(sourceFile.getBaseName());
3815
+ if (!side)
3816
+ continue;
3817
+ for (const part of referencedHandshakes(sourceFile, byPath, registry)) {
3818
+ sides
3819
+ .get(part)?.[side].push(relative(builder.rootDir, sourceFile.getFilePath()));
3820
+ }
3821
+ }
3822
+ for (const [part, reached] of sides) {
3823
+ addNode(builder, {
3824
+ id: handshakeNodeId(part.sourceFile.getFilePath(), part.variableName),
3825
+ kind: 'handshake',
3826
+ label: part.name ?? part.variableName,
3827
+ filePath: part.sourceFile.getFilePath(),
3828
+ line: part.line,
3829
+ details: {
3830
+ ...(part.name === undefined
3831
+ ? { static: false }
3832
+ : { handshakeName: part.name }),
3833
+ handshakeVariable: part.variableName,
3834
+ ...(reached.server.length ? { serverSites: reached.server } : {}),
3835
+ ...(reached.client.length ? { clientSites: reached.client } : {}),
3836
+ },
3837
+ });
3838
+ }
3839
+ }
3840
+ /**
3841
+ * Résout un identifiant vers le nom du `craftHandshake(...)` qu'il désigne,
3842
+ * déclaré localement ou importé. Autonome à dessein : l'identité des server
3843
+ * functions est collectée avant les handshakes.
3844
+ */
3845
+ function resolveHandshakeName(sourceFile, name, byPath) {
3846
+ const local = handshakeNameOfDeclaration(sourceFile, name);
3847
+ if (local !== undefined)
3848
+ return local;
3849
+ for (const declaration of sourceFile.getImportDeclarations()) {
3850
+ if (declaration.isTypeOnly())
3851
+ continue;
3852
+ const named = declaration
3853
+ .getNamedImports()
3854
+ .find((candidate) => (candidate.getAliasNode()?.getText() ?? candidate.getName()) === name);
3855
+ if (!named || named.isTypeOnly())
3856
+ continue;
3857
+ const imported = resolveImportedSource(sourceFile, declaration.getModuleSpecifierValue(), sourceFile.getProject());
3858
+ if (!imported || !byPath.has(imported.getFilePath()))
3859
+ continue;
3860
+ const resolved = handshakeNameOfDeclaration(imported, named.getName());
3861
+ if (resolved !== undefined)
3862
+ return resolved;
3863
+ }
3864
+ return undefined;
3865
+ }
3866
+ function handshakeNameOfDeclaration(sourceFile, variableName) {
3867
+ const initializer = sourceFile
3868
+ .getVariableDeclaration(variableName)
3869
+ ?.getInitializer();
3870
+ if (!initializer)
3871
+ return undefined;
3872
+ const call = [
3873
+ ...(Node.isCallExpression(initializer) ? [initializer] : []),
3874
+ ...initializer.getDescendantsOfKind(SyntaxKind.CallExpression),
3875
+ ].find((candidate) => candidate.getExpression().getText() === 'craftHandshake');
3876
+ return call
3877
+ ?.getArguments()[0]
3878
+ ?.asKind(SyntaxKind.StringLiteral)
3879
+ ?.getLiteralValue();
3880
+ }
3881
+ function handshakeKey(filePath, variableName) {
3882
+ return `${filePath}#${variableName}`;
3883
+ }
3884
+ function handshakeNodeId(filePath, variableName) {
3885
+ return `handshake:${handshakeKey(filePath, variableName)}`;
3886
+ }
3887
+ /** De quel côté de la frontière un fichier parle, d'après sa convention de nom. */
3888
+ function handshakeSide(baseName) {
3889
+ if (baseName.endsWith('.fn-serveur.ts') || isServerMiddlewareFile(baseName)) {
3890
+ return 'server';
3891
+ }
3892
+ if (baseName.endsWith('.fn-client.ts') || isClientMiddlewareFile(baseName)) {
3893
+ return 'client';
3894
+ }
3895
+ return undefined;
3896
+ }
3897
+ /** Les handshakes qu'un fichier référence, déclarés localement ou importés. */
3898
+ function referencedHandshakes(sourceFile, byPath, registry) {
3899
+ const found = new Set();
3900
+ for (const identifier of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
3901
+ const local = registry.get(handshakeKey(sourceFile.getFilePath(), identifier.getText()));
3902
+ if (local &&
3903
+ identifier.getParent()?.getKind() !== SyntaxKind.VariableDeclaration) {
3904
+ found.add(local);
3905
+ }
3906
+ }
3907
+ for (const declaration of sourceFile.getImportDeclarations()) {
3908
+ if (declaration.isTypeOnly())
3909
+ continue;
3910
+ const imported = resolveImportedSource(sourceFile, declaration.getModuleSpecifierValue(), sourceFile.getProject());
3911
+ if (!imported || !byPath.has(imported.getFilePath()))
3912
+ continue;
3913
+ for (const named of declaration.getNamedImports()) {
3914
+ if (named.isTypeOnly())
3915
+ continue;
3916
+ const part = registry.get(handshakeKey(imported.getFilePath(), named.getName()));
3917
+ if (part)
3918
+ found.add(part);
3919
+ }
3920
+ }
3921
+ return [...found];
3922
+ }
3923
+ function clientMiddlewareNodeId(filePath, variableName) {
3924
+ return `client-function-middleware:${middlewareKey(filePath, variableName)}`;
3925
+ }
3926
+ /**
3927
+ * Identifiants passés à `craftClientMiddleware(...)` dans une façade client.
3928
+ * Les arguments doivent être des identifiants statiques : une valeur construite
3929
+ * dynamiquement échappe au graphe, comme partout ailleurs dans cette analyse.
3930
+ */
3931
+ function findClientContextAttachments(sourceFile) {
3932
+ const names = [];
3933
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
3934
+ if (call.getExpression().getText() !== 'craftClientMiddleware')
3935
+ continue;
3936
+ for (const argument of call.getArguments()) {
3937
+ const identifier = argument.asKind(SyntaxKind.Identifier);
3938
+ if (identifier)
3939
+ names.push(identifier.getText());
3940
+ }
3941
+ }
3942
+ return names;
3943
+ }
3944
+ /**
3945
+ * Clés du contexte client réellement lues côté serveur
3946
+ * (`context.clientContext.<clé>`), tous fichiers serveur confondus.
3947
+ *
3948
+ * Heuristique assumée : une lecture indirecte (déstructuration réexportée,
3949
+ * accès par variable) n'est pas vue. Le diagnostic qui s'en sert le dit.
3950
+ */
3951
+ function collectClientContextReads(sourceFiles) {
3952
+ const reads = new Set();
3953
+ for (const sourceFile of sourceFiles) {
3954
+ const baseName = sourceFile.getBaseName();
3955
+ if (!baseName.endsWith('.fn-serveur.ts') &&
3956
+ !isServerMiddlewareFile(baseName)) {
3957
+ continue;
3958
+ }
3959
+ for (const access of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
3960
+ if (!access.getExpression().getText().endsWith('clientContext'))
3961
+ continue;
3962
+ reads.add(access.getName());
3963
+ }
3964
+ for (const pattern of sourceFile.getDescendantsOfKind(SyntaxKind.ObjectBindingPattern)) {
3965
+ const initializer = pattern
3966
+ .getParentIfKind(SyntaxKind.VariableDeclaration)
3967
+ ?.getInitializer();
3968
+ if (initializer?.getText().endsWith('clientContext') !== true)
3969
+ continue;
3970
+ for (const element of pattern.getElements()) {
3971
+ reads.add(element.getPropertyNameNode()?.getText() ?? element.getName());
3972
+ }
3973
+ }
3974
+ }
3975
+ return reads;
3976
+ }
3977
+ function middlewareKey(filePath, variableName) {
3978
+ return `${filePath}#${variableName}`;
3979
+ }
3980
+ function middlewareNodeId(filePath, variableName) {
3981
+ return `server-function-middleware:${middlewareKey(filePath, variableName)}`;
3982
+ }
3983
+ function findCraftMiddlewares(sourceFile, byPath) {
3984
+ const parts = [];
3985
+ for (const declaration of sourceFile.getVariableDeclarations()) {
3986
+ const initializer = declaration.getInitializer();
3987
+ if (!initializer)
3988
+ continue;
3989
+ const candidates = [
3990
+ ...(Node.isCallExpression(initializer) ? [initializer] : []),
3991
+ ...initializer.getDescendantsOfKind(SyntaxKind.CallExpression),
3992
+ ];
3993
+ // `craftHandshakeMiddleware(handshake, run)` est un middleware client
3994
+ // complet en une déclaration : son id et son schéma viennent du handshake.
3995
+ const handshakeCall = candidates.find((candidate) => candidate.getExpression().getText() === 'craftHandshakeMiddleware');
3996
+ if (handshakeCall && byPath) {
3997
+ const reference = handshakeCall
3998
+ .getArguments()[0]
3999
+ ?.asKind(SyntaxKind.Identifier);
4000
+ const name = reference
4001
+ ? resolveHandshakeName(sourceFile, reference.getText(), byPath)
4002
+ : undefined;
4003
+ parts.push({
4004
+ sourceFile,
4005
+ variableName: declaration.getName(),
4006
+ ...(name === undefined ? {} : { id: name }),
4007
+ uses: [],
4008
+ line: declaration.getStartLineNumber(),
4009
+ terminal: 'client',
4010
+ providesKeys: [],
4011
+ });
4012
+ continue;
4013
+ }
4014
+ // `serverLayer(...)` compose un programme plutôt qu'un Effect, mais c'est
4015
+ // la même famille pour le graphe : mêmes règles d'id, de nommage de
4016
+ // fichier et de frontière client/serveur.
4017
+ const layerCall = candidates.find((candidate) => ['serverLayer', 'serverLayerReading'].includes(candidate.getExpression().getText()));
4018
+ if (layerCall) {
4019
+ // `serverLayerReading<Ctx>()('id', run)` : l'identifiant est porté par
4020
+ // l'appel extérieur, celui qui suit la curryfication.
4021
+ const idCall = layerCall.getExpression().getText() === 'serverLayerReading'
4022
+ ? layerCall.getParentIfKind(SyntaxKind.CallExpression)
4023
+ : layerCall;
4024
+ const layerId = idCall
4025
+ ?.getArguments()[0]
4026
+ ?.asKind(SyntaxKind.StringLiteral)
4027
+ ?.getLiteralValue();
4028
+ parts.push({
4029
+ sourceFile,
4030
+ variableName: declaration.getName(),
4031
+ ...(layerId === undefined ? {} : { id: layerId }),
4032
+ uses: [],
4033
+ line: declaration.getStartLineNumber(),
4034
+ terminal: 'server',
4035
+ providesKeys: [],
4036
+ composition: 'pipe',
4037
+ });
4038
+ continue;
4039
+ }
4040
+ const call = candidates.find((candidate) => [
4041
+ 'craftMiddleware',
4042
+ 'portableServerMiddleware',
4043
+ 'effectServerMiddleware',
4044
+ ].includes(candidate.getExpression().getText()));
4045
+ if (!call)
4046
+ continue;
4047
+ const id = call
4048
+ .getArguments()[0]
4049
+ ?.asKind(SyntaxKind.StringLiteral)
4050
+ ?.getLiteralValue();
4051
+ const methods = chainedCallMethods(call);
4052
+ const constructor = call.getExpression().getText();
4053
+ const terminal = constructor === 'portableServerMiddleware' ||
4054
+ constructor === 'effectServerMiddleware'
4055
+ ? 'server'
4056
+ : methods.includes('client')
4057
+ ? 'client'
4058
+ : methods.includes('server')
4059
+ ? 'server'
4060
+ : undefined;
4061
+ parts.push({
4062
+ sourceFile,
4063
+ variableName: declaration.getName(),
4064
+ ...(id === undefined ? {} : { id }),
4065
+ uses: [
4066
+ ...chainedCallArguments(call, 'use'),
4067
+ ...chainedCallIdentifierArguments(call, 'pipe'),
4068
+ ],
4069
+ line: declaration.getStartLineNumber(),
4070
+ ...(terminal === undefined ? {} : { terminal }),
4071
+ providesKeys: chainedCallObjectKeys(call, 'provides'),
4072
+ composition: chainedCallIdentifierArguments(call, 'pipe').length
4073
+ ? 'pipe'
4074
+ : 'use',
4075
+ });
4076
+ }
4077
+ return parts;
4078
+ }
4079
+ /** Résout un identifiant de middleware, déclaré localement ou importé. */
4080
+ function resolveMiddlewareReference(sourceFile, name, byPath, registry) {
4081
+ const local = registry.get(middlewareKey(sourceFile.getFilePath(), name));
4082
+ if (local)
4083
+ return local;
4084
+ for (const declaration of sourceFile.getImportDeclarations()) {
4085
+ const named = declaration
4086
+ .getNamedImports()
4087
+ .find((candidate) => (candidate.getAliasNode()?.getText() ?? candidate.getName()) === name);
4088
+ if (!named)
4089
+ continue;
4090
+ const imported = resolveImportedSource(sourceFile, declaration.getModuleSpecifierValue(), sourceFile.getProject());
4091
+ if (!imported || !byPath.has(imported.getFilePath()))
4092
+ continue;
4093
+ return registry.get(middlewareKey(imported.getFilePath(), named.getName()));
4094
+ }
4095
+ return undefined;
4096
+ }
4097
+ function serverFunctionSuffix(baseName) {
4098
+ if (baseName.endsWith('.fn-contract.ts'))
4099
+ return '.fn-contract.ts';
4100
+ if (baseName.endsWith('.fn-client.ts'))
4101
+ return '.fn-client.ts';
4102
+ if (baseName.endsWith('.fn-serveur.ts'))
4103
+ return '.fn-serveur.ts';
4104
+ return undefined;
4105
+ }
4106
+ function serverFunctionImports(sourceFile, byPath) {
4107
+ const server = [];
4108
+ const client = [];
4109
+ const serverOnly = [];
4110
+ const middleware = [];
4111
+ const clientMiddleware = [];
4112
+ for (const declaration of sourceFile.getImportDeclarations()) {
4113
+ if (declaration.isTypeOnly())
4114
+ continue;
4115
+ const imported = resolveImportedSource(sourceFile, declaration.getModuleSpecifierValue(), sourceFile.getProject());
4116
+ if (!imported || !byPath.has(imported.getFilePath()))
4117
+ continue;
4118
+ const suffix = serverFunctionSuffix(imported.getBaseName());
4119
+ if (suffix === '.fn-serveur.ts')
4120
+ server.push(imported.getFilePath());
4121
+ if (suffix === '.fn-client.ts')
4122
+ client.push(imported.getFilePath());
4123
+ if (imported.getBaseName().includes('.server.')) {
4124
+ serverOnly.push(imported.getFilePath());
4125
+ }
4126
+ if (isServerMiddlewareFile(imported.getBaseName())) {
4127
+ middleware.push(imported.getFilePath());
4128
+ }
4129
+ if (isClientMiddlewareFile(imported.getBaseName())) {
4130
+ clientMiddleware.push(imported.getFilePath());
4131
+ }
4132
+ }
4133
+ return { server, client, serverOnly, middleware, clientMiddleware };
4134
+ }
4135
+ function isServerMiddlewareFile(baseName) {
4136
+ return baseName.endsWith('.mw-serveur.ts');
4137
+ }
4138
+ function isClientMiddlewareFile(baseName) {
4139
+ return baseName.endsWith('.mw-client.ts');
4140
+ }
4141
+ /**
4142
+ * Où un `craftMiddleware(...).client(...)` a le droit de vivre.
4143
+ *
4144
+ * `*.mw-client.ts` pour ce qui est réutilisable, mais aussi `*.fn-client.ts` :
4145
+ * une façade est déjà un module purement navigateur, déjà tenue à l'écart du
4146
+ * bundle serveur par les règles de famille, et c'est là que le contexte
4147
+ * d'injection est disponible sans cérémonie. Interdire un middleware d'un seul
4148
+ * usage à côté de la façade qui l'utilise coûterait un fichier pour rien.
4149
+ */
4150
+ function declaresClientMiddleware(baseName) {
4151
+ return isClientMiddlewareFile(baseName) || baseName.endsWith('.fn-client.ts');
4152
+ }
4153
+ function findServerFunctionContract(sourceFile) {
4154
+ const call = sourceFile
4155
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
4156
+ .find((candidate) => candidate.getExpression().getText() === 'serverFunctionContract');
4157
+ const object = call
4158
+ ?.getArguments()[0]
4159
+ ?.asKind(SyntaxKind.ObjectLiteralExpression);
4160
+ if (!object)
4161
+ return undefined;
4162
+ return {
4163
+ id: getStringProperty(object, 'id'),
4164
+ exposure: getStringProperty(object, 'exposure'),
4165
+ };
4166
+ }
4167
+ function findServerFunction(sourceFile, byPath) {
4168
+ const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
4169
+ const serverCall = calls.find((candidate) => {
4170
+ const expression = candidate.getExpression().getText();
4171
+ return (expression === 'serverFunction' || expression === 'portableServerFunction');
4172
+ });
4173
+ if (!serverCall)
4174
+ return undefined;
4175
+ const first = serverCall.getArguments()[0];
4176
+ const directId = first?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
4177
+ const firstIdentifier = first?.asKind(SyntaxKind.Identifier);
4178
+ const handshakeId = firstIdentifier
4179
+ ? resolveHandshakeName(sourceFile, firstIdentifier.getText(), byPath)
4180
+ : undefined;
4181
+ // Un handshake porte l'id ; seul un identifiant qui n'en est pas un désigne
4182
+ // un contrat importé d'un `*.fn-contract.ts`.
4183
+ const contractIdentifier = handshakeId === undefined ? firstIdentifier : undefined;
4184
+ let contractFamily;
4185
+ let id = directId ?? handshakeId;
4186
+ if (contractIdentifier) {
4187
+ const importDeclaration = sourceFile
4188
+ .getImportDeclarations()
4189
+ .find((declaration) => declaration
4190
+ .getNamedImports()
4191
+ .some((named) => named.getAliasNode()?.getText() ===
4192
+ contractIdentifier.getText() ||
4193
+ named.getName() === contractIdentifier.getText()));
4194
+ const imported = importDeclaration
4195
+ ? resolveImportedSource(sourceFile, importDeclaration.getModuleSpecifierValue(), sourceFile.getProject())
4196
+ : undefined;
4197
+ if (imported && byPath.has(imported.getFilePath())) {
4198
+ contractFamily = imported.getFilePath().replace(/\.fn-contract\.ts$/, '');
4199
+ const contract = findServerFunctionContract(imported);
4200
+ id = contract?.id;
4201
+ }
4202
+ }
4203
+ const options = serverCall
4204
+ .getArguments()[2]
4205
+ ?.asKind(SyntaxKind.ObjectLiteralExpression);
4206
+ // Une fonction qui déclare attendre un contexte du navigateur n'a de sens
4207
+ // que si un navigateur peut l'appeler.
4208
+ const declaresClientContext = options?.getProperty('clientContext') !== undefined;
4209
+ const middlewareUses = chainedCallArguments(serverCall, 'use');
4210
+ const layerPipes = chainedCallIdentifierArguments(serverCall, 'pipe');
4211
+ return {
4212
+ id,
4213
+ middlewareUses,
4214
+ layerPipes,
4215
+ exposure: contractIdentifier === undefined
4216
+ ? (getStringProperty(options, 'exposure') ?? 'server')
4217
+ : 'client',
4218
+ declaresClientContext,
4219
+ ...(contractFamily === undefined ? {} : { contractFamily }),
4220
+ };
4221
+ }
4222
+ function findClientContractFamily(sourceFile, byPath) {
4223
+ const call = sourceFile
4224
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
4225
+ .find((candidate) => candidate.getExpression().getText() === 'createServerFunctionClient');
4226
+ if (!call)
4227
+ return undefined;
4228
+ const identity = findClientIdentity(call, byPath);
4229
+ const serverDefinition = findClientServerDefinition(sourceFile, call, byPath);
4230
+ if (serverDefinition) {
4231
+ return {
4232
+ id: identity.id ?? serverDefinition.id,
4233
+ family: sourceFile.getFilePath().replace(/\.fn-client\.ts$/, ''),
4234
+ definitionFile: serverDefinition.filePath,
4235
+ ...(identity.usesCraftUnique === undefined
4236
+ ? {}
4237
+ : { usesCraftUnique: identity.usesCraftUnique }),
4238
+ ...(identity.identityStatic === undefined
4239
+ ? {}
4240
+ : { identityStatic: identity.identityStatic }),
4241
+ };
4242
+ }
4243
+ const identifier = call?.getArguments()[0]?.asKind(SyntaxKind.Identifier);
4244
+ const directId = call
4245
+ ?.getArguments()[0]
4246
+ ?.asKind(SyntaxKind.StringLiteral)
4247
+ ?.getLiteralValue();
4248
+ const declaration = identifier
4249
+ ? sourceFile
4250
+ .getImportDeclarations()
4251
+ .find((candidate) => candidate
4252
+ .getNamedImports()
4253
+ .some((named) => named.getName() === identifier.getText() ||
4254
+ named.getAliasNode()?.getText() === identifier.getText()))
4255
+ : undefined;
4256
+ const imported = declaration
4257
+ ? resolveImportedSource(sourceFile, declaration.getModuleSpecifierValue(), sourceFile.getProject())
4258
+ : undefined;
4259
+ if (imported && byPath.has(imported.getFilePath())) {
4260
+ const contract = findServerFunctionContract(imported);
4261
+ return {
4262
+ id: identity.id ?? contract?.id,
4263
+ family: sourceFile.getFilePath().replace(/\.fn-client\.ts$/, ''),
4264
+ ...(identity.usesCraftUnique === undefined
4265
+ ? {}
4266
+ : { usesCraftUnique: identity.usesCraftUnique }),
4267
+ ...(identity.identityStatic === undefined
4268
+ ? {}
4269
+ : { identityStatic: identity.identityStatic }),
4270
+ };
4271
+ }
4272
+ const serverImport = sourceFile.getImportDeclarations().find((candidate) => {
4273
+ const resolved = resolveImportedSource(sourceFile, candidate.getModuleSpecifierValue(), sourceFile.getProject());
4274
+ return resolved?.getBaseName().endsWith('.fn-serveur.ts') === true;
4275
+ });
4276
+ const server = serverImport
4277
+ ? resolveImportedSource(sourceFile, serverImport.getModuleSpecifierValue(), sourceFile.getProject())
4278
+ : undefined;
4279
+ if (!server || !byPath.has(server.getFilePath()))
4280
+ return undefined;
4281
+ const serverFunction = findServerFunction(server, byPath);
4282
+ return {
4283
+ id: identity.id ?? directId ?? serverFunction?.id,
4284
+ family: sourceFile.getFilePath().replace(/\.fn-client\.ts$/, ''),
4285
+ definitionFile: server.getFilePath(),
4286
+ ...(identity.usesCraftUnique === undefined
4287
+ ? {}
4288
+ : { usesCraftUnique: identity.usesCraftUnique }),
4289
+ ...(identity.identityStatic === undefined
4290
+ ? {}
4291
+ : { identityStatic: identity.identityStatic }),
4292
+ };
4293
+ }
4294
+ function findClientIdentity(call, byPath) {
4295
+ const argument = call.getArguments()[0];
4296
+ const identifier = argument?.asKind(SyntaxKind.Identifier);
4297
+ if (identifier) {
4298
+ // Un handshake référencé vaut identité partagée : les deux côtés passent la
4299
+ // même valeur, donc l'égalité des ids est déjà tenue par TypeScript.
4300
+ const handshake = resolveHandshakeName(call.getSourceFile(), identifier.getText(), byPath);
4301
+ if (handshake !== undefined) {
4302
+ return {
4303
+ id: handshake,
4304
+ usesHandshake: true,
4305
+ usesCraftUnique: true,
4306
+ identityStatic: true,
4307
+ };
4308
+ }
4309
+ }
4310
+ if (!argument?.isKind(SyntaxKind.CallExpression)) {
4311
+ return {};
4312
+ }
4313
+ if (argument.getExpression().getText() !== 'craftUnique') {
4314
+ return { usesCraftUnique: false };
4315
+ }
4316
+ const value = argument.getArguments()[0];
4317
+ if (!value ||
4318
+ (!value.isKind(SyntaxKind.StringLiteral) &&
4319
+ !value.isKind(SyntaxKind.NoSubstitutionTemplateLiteral))) {
4320
+ return { usesCraftUnique: true, identityStatic: false };
4321
+ }
4322
+ return {
4323
+ id: value.getLiteralValue(),
4324
+ usesCraftUnique: true,
4325
+ identityStatic: true,
4326
+ };
4327
+ }
4328
+ function findClientServerDefinition(sourceFile, call, byPath) {
4329
+ const typeArgument = call.getTypeArguments()[0];
4330
+ const match = /^typeof\s+([A-Za-z_$][\w$]*)$/.exec(typeArgument?.getText() ?? '');
4331
+ if (!match)
4332
+ return undefined;
4333
+ const importedName = match[1];
4334
+ const importDeclaration = sourceFile
4335
+ .getImportDeclarations()
4336
+ .find((declaration) => declaration
4337
+ .getNamedImports()
4338
+ .some((specifier) => (specifier.getAliasNode()?.getText() ?? specifier.getName()) ===
4339
+ importedName));
4340
+ if (!importDeclaration)
4341
+ return undefined;
4342
+ const imported = resolveImportedSource(sourceFile, importDeclaration.getModuleSpecifierValue(), sourceFile.getProject());
4343
+ if (!imported || !byPath.has(imported.getFilePath()))
4344
+ return undefined;
4345
+ if (!imported.getBaseName().endsWith('.fn-serveur.ts'))
4346
+ return undefined;
4347
+ const server = findServerFunction(imported, byPath);
4348
+ return {
4349
+ filePath: imported.getFilePath(),
4350
+ id: server?.id,
4351
+ };
4352
+ }
4353
+ function addEdge(builder, from, to, kind, evidence, details, proof) {
4354
+ if (from === to)
4355
+ return;
4356
+ const key = `${from}:${kind}:${to}`;
4357
+ const existing = builder.edges.get(key);
4358
+ if (existing) {
4359
+ if (details) {
4360
+ const previousUsage = existing.details?.['usage'];
4361
+ existing.details = { ...(existing.details ?? {}), ...details };
4362
+ if (previousUsage && details['usage']) {
4363
+ existing.details['usage'] = mergeUsageValues(previousUsage, details['usage']);
4364
+ }
4365
+ }
4366
+ if (proof && !existing.proof)
4367
+ existing.proof = proof;
4368
+ return;
4369
+ }
4370
+ builder.edges.set(key, { from, to, kind, evidence, details, proof });
4371
+ }
4372
+ function mergeUsageDetail(node, key, usage) {
4373
+ if (!node.details)
4374
+ node.details = {};
4375
+ const previous = node.details[key];
4376
+ node.details[key] = previous ? mergeUsageValues(previous, usage) : usage;
4377
+ }
4378
+ function mergeUsageValues(previous, next) {
4379
+ return [
4380
+ ...new Set([String(previous), String(next)].flatMap((value) => value.split('+'))),
4381
+ ]
4382
+ .sort()
4383
+ .join('+');
4384
+ }
4385
+ function getStringArgument(call, index) {
4386
+ return call
4387
+ .getArguments()[index]?.asKind(SyntaxKind.StringLiteral)
4388
+ ?.getLiteralValue();
4389
+ }
4390
+ function getStringProperty(object, name) {
4391
+ return object
4392
+ ?.getProperty(name)
4393
+ ?.asKind(SyntaxKind.PropertyAssignment)
4394
+ ?.getInitializer()
4395
+ ?.asKind(SyntaxKind.StringLiteral)
4396
+ ?.getLiteralValue();
4397
+ }
4398
+ function getBooleanProperty(object, name) {
4399
+ const initializer = object
4400
+ ?.getProperty(name)
4401
+ ?.asKind(SyntaxKind.PropertyAssignment)
4402
+ ?.getInitializer();
4403
+ if (!initializer)
4404
+ return undefined;
4405
+ if (initializer.getKind() === SyntaxKind.TrueKeyword)
4406
+ return true;
4407
+ if (initializer.getKind() === SyntaxKind.FalseKeyword)
4408
+ return false;
4409
+ return undefined;
4410
+ }
4411
+ function addBindingNames(node, target) {
4412
+ for (const name of getBindingNames(node))
4413
+ target.add(name);
4414
+ }
4415
+ function getBindingNames(node) {
4416
+ if (Node.isIdentifier(node))
4417
+ return [node.getText()];
4418
+ if (Node.isObjectBindingPattern(node)) {
4419
+ return node.getElements().flatMap((element) => {
4420
+ if (!Node.isBindingElement(element))
4421
+ return [];
4422
+ return getBindingNames(element.getNameNode());
4423
+ });
4424
+ }
4425
+ if (Node.isArrayBindingPattern(node)) {
4426
+ return node
4427
+ .getElements()
4428
+ .flatMap((element) => Node.isBindingElement(element)
4429
+ ? getBindingNames(element.getNameNode())
4430
+ : []);
4431
+ }
4432
+ return [];
4433
+ }
4434
+ function inferNameFromServiceDeclaration(call) {
4435
+ const declaration = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
4436
+ const name = declaration?.getNameNode();
4437
+ return name && Node.isIdentifier(name) ? name.getText() : undefined;
4438
+ }
4439
+ function detectTsConfig(rootDir) {
4440
+ for (const candidate of ['tsconfig.app.json', 'tsconfig.json']) {
4441
+ const path = resolve(rootDir, candidate);
4442
+ if (existsSync(path))
4443
+ return candidate;
4444
+ }
4445
+ return 'tsconfig.json';
4446
+ }
4447
+ function escapeMermaid(value) {
4448
+ return value
4449
+ .split('"')
4450
+ .join('\\"')
4451
+ .split('|')
4452
+ .join('/')
4453
+ .split('\n')
4454
+ .join(' ');
4455
+ }
4456
+ //# sourceMappingURL=dependency-graph.js.map