@craft-ts/dev-tools 0.7.0-beta.13 → 0.7.0-beta.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -10
- package/package.json +8 -2
- package/src/bin/agent-selector.d.ts +22 -0
- package/src/bin/agent-selector.js +100 -0
- package/src/bin/agent-selector.js.map +1 -0
- package/src/bin/craft-graph.js +74 -8
- package/src/bin/craft-graph.js.map +1 -1
- package/src/bin/craft.d.ts +22 -0
- package/src/bin/craft.js +231 -28
- package/src/bin/craft.js.map +1 -1
- package/src/eslint-rules/anchor-has-href.cjs +37 -3
- package/src/eslint-rules/craft-computed-name-match.cjs +26 -5
- package/src/eslint-rules/craft-name-match-utils.cjs +18 -9
- package/src/eslint-rules/hyperscript-walk.cjs +3 -3
- package/src/eslint-rules/index.cjs +46 -1
- package/src/eslint-rules/no-auth-token-in-local-storage.cjs +17 -0
- package/src/eslint-rules/no-craft-computed-side-effects.cjs +12 -5
- package/src/eslint-rules/no-craft-service-component-same-file.cjs +138 -0
- package/src/eslint-rules/no-effect-import-in-frontend.cjs +52 -0
- package/src/eslint-rules/no-effect-in-params.cjs +31 -1
- package/src/eslint-rules/no-explicit-effect-type.cjs +137 -0
- package/src/eslint-rules/no-free-has.cjs +47 -0
- package/src/eslint-rules/no-heading-level-skip.cjs +6 -6
- package/src/eslint-rules/no-raw-class.cjs +76 -0
- package/src/eslint-rules/no-raw-css-value.cjs +163 -0
- package/src/eslint-rules/no-raw-user-url.cjs +114 -0
- package/src/eslint-rules/no-trust-forwarded-headers.cjs +43 -0
- package/src/eslint-rules/no-unsafe-html.cjs +93 -0
- package/src/eslint-rules/no-unsafe-transfer-state.cjs +54 -0
- package/src/eslint-rules/prefer-craft-template-blocks.cjs +8 -8
- package/src/eslint-rules/prefer-direct-yieldable-callback.cjs +57 -14
- package/src/eslint-rules/prefer-inline-effect-insertion.cjs +263 -0
- package/src/eslint-rules/prefer-inline-route-providers.cjs +138 -0
- package/src/eslint-rules/recommended-config.cjs +6 -0
- package/src/eslint-rules/require-reactive-template-bindings.cjs +8 -8
- package/src/eslint-rules/require-route-security-policy.cjs +87 -0
- package/src/eslint-rules/require-server-function-timeout.cjs +39 -0
- package/src/eslint-rules/security-config.cjs +14 -0
- package/src/eslint-rules/security-rule-utils.cjs +21 -0
- package/src/eslint-rules/style-file-boundary.cjs +75 -0
- package/src/eslint-rules/sync-effect-body.cjs +343 -0
- package/src/index.d.ts +4 -0
- package/src/index.js +4 -0
- package/src/index.js.map +1 -1
- package/src/scripts/architecture-graph.js +8 -2
- package/src/scripts/architecture-graph.js.map +1 -1
- package/src/scripts/create/create-project.d.ts +60 -1
- package/src/scripts/create/create-project.js +2082 -184
- package/src/scripts/create/create-project.js.map +1 -1
- package/src/scripts/data-flow-graph.d.ts +1 -1
- package/src/scripts/effect-dependency-graph.d.ts +1 -1
- package/src/scripts/security-check.d.ts +30 -0
- package/src/scripts/security-check.js +223 -0
- package/src/scripts/security-check.js.map +1 -0
- package/src/scripts/style-architecture.d.ts +56 -0
- package/src/scripts/style-architecture.js +142 -0
- package/src/scripts/style-architecture.js.map +1 -0
- package/src/scripts/style-graph.d.ts +99 -0
- package/src/scripts/style-graph.js +179 -0
- package/src/scripts/style-graph.js.map +1 -0
- package/src/scripts/style-report.d.ts +55 -0
- package/src/scripts/style-report.js +67 -0
- package/src/scripts/style-report.js.map +1 -0
- package/src/scripts/template/migrate-template.js +1 -1
- package/src/scripts/template/migrate-template.js.map +1 -1
- package/src/scripts/angular-brand-codemod.ts +0 -3985
- package/src/scripts/architecture/migrate-architecture.ts +0 -653
- package/src/scripts/architecture-graph.ts +0 -3136
- package/src/scripts/components/migrate-components.ts +0 -227
- package/src/scripts/components/migration-diagnostic.ts +0 -8
- package/src/scripts/create/create-project.ts +0 -849
- package/src/scripts/data-flow-graph.ts +0 -240
- package/src/scripts/dependency-graph.ts +0 -5908
- package/src/scripts/effect-dependency-graph.ts +0 -630
- package/src/scripts/migrate.ts +0 -162
- package/src/scripts/migration-workspace.ts +0 -93
- package/src/scripts/primitives/migrate-named-primitives.ts +0 -627
- package/src/scripts/primitives/migrate-primitive-generators.ts +0 -410
- package/src/scripts/primitives/migrate-primitives.ts +0 -655
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.ts +0 -499
- package/src/scripts/primitives/migration-diagnostic.ts +0 -15
- package/src/scripts/routes/migrate-routes.ts +0 -895
- package/src/scripts/routes/migration-diagnostic.ts +0 -25
- package/src/scripts/routes/route-command.ts +0 -1807
- package/src/scripts/routes/verify-routes.ts +0 -1511
- package/src/scripts/services/config.ts +0 -60
- package/src/scripts/services/migrate-services.ts +0 -1348
- package/src/scripts/services/migration-diagnostic.ts +0 -28
- package/src/scripts/template/migrate-template.ts +0 -418
|
@@ -1,630 +0,0 @@
|
|
|
1
|
-
import { Node, SyntaxKind, type SourceFile } from 'ts-morph';
|
|
2
|
-
import type {
|
|
3
|
-
DependencyGraphEdge,
|
|
4
|
-
DependencyGraphNode,
|
|
5
|
-
DependencyGraphProof,
|
|
6
|
-
} from './dependency-graph';
|
|
7
|
-
|
|
8
|
-
export interface EffectServiceNodeDetails {
|
|
9
|
-
readonly runtime: 'effect';
|
|
10
|
-
readonly tag: string;
|
|
11
|
-
readonly serviceName: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface EffectServiceRequirementDetails {
|
|
15
|
-
readonly runtime: 'effect';
|
|
16
|
-
readonly selection: 'whole-service' | 'member';
|
|
17
|
-
readonly member?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface EffectOperationNodeDetails {
|
|
21
|
-
readonly runtime: 'effect';
|
|
22
|
-
readonly operationName: string;
|
|
23
|
-
readonly resolution: 'static' | 'partial' | 'unknown';
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface EffectLayerNodeDetails {
|
|
27
|
-
readonly runtime: 'effect';
|
|
28
|
-
readonly layerName: string;
|
|
29
|
-
readonly providedServices: readonly string[];
|
|
30
|
-
readonly requiredServices: readonly string[];
|
|
31
|
-
readonly compositions: readonly string[];
|
|
32
|
-
readonly resolution: 'complete' | 'partial' | 'unknown';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface EffectLayerRelationDetails {
|
|
36
|
-
readonly runtime: 'effect';
|
|
37
|
-
readonly service: string;
|
|
38
|
-
readonly resolution: 'complete' | 'partial' | 'unknown';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
declare module './dependency-graph' {
|
|
42
|
-
interface DependencyGraphNodeRegistry {
|
|
43
|
-
'effect-service': EffectServiceNodeDetails;
|
|
44
|
-
'effect-operation': EffectOperationNodeDetails;
|
|
45
|
-
'effect-layer': EffectLayerNodeDetails;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface DependencyGraphEdgeRegistry {
|
|
49
|
-
'requires-service': EffectServiceRequirementDetails;
|
|
50
|
-
'provided-by-layer': EffectLayerRelationDetails;
|
|
51
|
-
'composes-layer': { readonly runtime: 'effect' };
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Task 3.3 — fine edges for Effect services.
|
|
57
|
-
//
|
|
58
|
-
// The graph already draws `Consumer -> Service.member` for craft services, via
|
|
59
|
-
// the injectX.property() shortcuts. This does the same for Effect ones, so that
|
|
60
|
-
// selecting `({ byId }) => ({ byId })` shows up as `UserStore -> UserApi.byId`
|
|
61
|
-
// rather than the useless `UserStore -> UserApi`.
|
|
62
|
-
//
|
|
63
|
-
// What the edge does NOT mean. A `Layer` builds the whole service regardless;
|
|
64
|
-
// nothing here reduces what Effect constructs at runtime. The edge records what
|
|
65
|
-
// a consumer actually *reaches for*, which is what makes the graph worth
|
|
66
|
-
// reading — and it is the only reason task 3.1's selection exists at all.
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
export type EffectServiceInfo = {
|
|
70
|
-
/** The class name, which is also the Context tag's identity. */
|
|
71
|
-
readonly name: string;
|
|
72
|
-
/** The string key given to Context.Service()(...). */
|
|
73
|
-
readonly key: string;
|
|
74
|
-
readonly filePath: string;
|
|
75
|
-
readonly line: number;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
export type EffectGraphContribution = {
|
|
79
|
-
readonly nodes: readonly DependencyGraphNode[];
|
|
80
|
-
readonly edges: readonly DependencyGraphEdge[];
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
const serviceId = (name: string) => `service:effect:${name}`;
|
|
84
|
-
const effectServiceId = (name: string) => `effect-service:${name}`;
|
|
85
|
-
const memberId = (name: string, member: string) =>
|
|
86
|
-
`property:${serviceId(name)}:${member}`;
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* `class UserApi extends Context.Service<UserApi, Shape>()('UserApi') {}`
|
|
90
|
-
*
|
|
91
|
-
* Matched on the *shape* of the heritage clause rather than on an import,
|
|
92
|
-
* because the tag is routinely re-exported through a barrel.
|
|
93
|
-
*/
|
|
94
|
-
export function collectEffectServices(
|
|
95
|
-
sourceFiles: readonly SourceFile[],
|
|
96
|
-
): Map<string, EffectServiceInfo> {
|
|
97
|
-
const services = new Map<string, EffectServiceInfo>();
|
|
98
|
-
|
|
99
|
-
for (const sourceFile of sourceFiles) {
|
|
100
|
-
for (const declaration of sourceFile.getClasses()) {
|
|
101
|
-
const heritage = declaration.getExtends();
|
|
102
|
-
if (!heritage) continue;
|
|
103
|
-
|
|
104
|
-
const expression = heritage.getExpression();
|
|
105
|
-
if (!Node.isCallExpression(expression)) continue;
|
|
106
|
-
|
|
107
|
-
// The outer call is `Context.Service<...>()('Name')`; its callee is the
|
|
108
|
-
// inner `Context.Service<...>()` call.
|
|
109
|
-
const inner = expression.getExpression();
|
|
110
|
-
if (!Node.isCallExpression(inner)) continue;
|
|
111
|
-
if (!/\bContext\.Service\b/.test(inner.getExpression().getText())) continue;
|
|
112
|
-
|
|
113
|
-
const name = declaration.getName();
|
|
114
|
-
if (!name) continue;
|
|
115
|
-
|
|
116
|
-
const keyArgument = expression.getArguments()[0];
|
|
117
|
-
const key =
|
|
118
|
-
keyArgument && Node.isStringLiteral(keyArgument)
|
|
119
|
-
? keyArgument.getLiteralValue()
|
|
120
|
-
: name;
|
|
121
|
-
|
|
122
|
-
services.set(name, {
|
|
123
|
-
name,
|
|
124
|
-
key,
|
|
125
|
-
filePath: sourceFile.getFilePath(),
|
|
126
|
-
line: declaration.getStartLineNumber(),
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return services;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The member names a selector picks: `({ byId }) => ({ byId })` -> ['byId']. */
|
|
135
|
-
export function selectedMemberNames(selector: Node): readonly string[] {
|
|
136
|
-
if (!Node.isArrowFunction(selector) && !Node.isFunctionExpression(selector)) {
|
|
137
|
-
return [];
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const names = new Set<string>();
|
|
141
|
-
|
|
142
|
-
// Preferred reading: the keys of the returned object literal, which is what
|
|
143
|
-
// the consumer will actually destructure.
|
|
144
|
-
const body = Node.isArrowFunction(selector) ? selector.getBody() : undefined;
|
|
145
|
-
const returned =
|
|
146
|
-
body && Node.isParenthesizedExpression(body)
|
|
147
|
-
? body.getExpression()
|
|
148
|
-
: body && Node.isObjectLiteralExpression(body)
|
|
149
|
-
? body
|
|
150
|
-
: undefined;
|
|
151
|
-
|
|
152
|
-
if (returned && Node.isObjectLiteralExpression(returned)) {
|
|
153
|
-
for (const property of returned.getProperties()) {
|
|
154
|
-
if (
|
|
155
|
-
Node.isPropertyAssignment(property) ||
|
|
156
|
-
Node.isShorthandPropertyAssignment(property)
|
|
157
|
-
) {
|
|
158
|
-
const nameNode = property.getNameNode();
|
|
159
|
-
if (Node.isIdentifier(nameNode)) names.add(nameNode.getText());
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Fallback: the destructured parameter, for selectors that rename on the way
|
|
165
|
-
// out (`({ byId }) => ({ fetchUser: byId })`).
|
|
166
|
-
if (names.size === 0) {
|
|
167
|
-
const parameter = selector.getParameters()[0];
|
|
168
|
-
const binding = parameter?.getNameNode();
|
|
169
|
-
if (binding && Node.isObjectBindingPattern(binding)) {
|
|
170
|
-
for (const element of binding.getElements()) {
|
|
171
|
-
const propertyName = element.getPropertyNameNode() ?? element.getNameNode();
|
|
172
|
-
if (Node.isIdentifier(propertyName)) names.add(propertyName.getText());
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return [...names];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export type EffectOperationOwner = {
|
|
181
|
-
readonly name: string;
|
|
182
|
-
readonly node: DependencyGraphNode;
|
|
183
|
-
readonly start: number;
|
|
184
|
-
readonly end: number;
|
|
185
|
-
readonly filePath: string;
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const operationId = (filePath: string, name: string) =>
|
|
189
|
-
`effect-operation:${filePath}#${name}`;
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Finds named Effect operations around direct service reads. This is the
|
|
193
|
-
* missing owner for standalone functions such as `checkUserAccess`: they are
|
|
194
|
-
* not Craft services or components, but they still form a meaningful graph
|
|
195
|
-
* boundary.
|
|
196
|
-
*/
|
|
197
|
-
export function collectEffectOperationOwners(
|
|
198
|
-
sourceFiles: readonly SourceFile[],
|
|
199
|
-
services: ReadonlyMap<string, EffectServiceInfo>,
|
|
200
|
-
): Map<string, EffectOperationOwner> {
|
|
201
|
-
const owners = new Map<string, EffectOperationOwner>();
|
|
202
|
-
|
|
203
|
-
for (const sourceFile of sourceFiles) {
|
|
204
|
-
const candidates = [
|
|
205
|
-
...sourceFile.getDescendantsOfKind(SyntaxKind.YieldExpression),
|
|
206
|
-
...sourceFile
|
|
207
|
-
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
208
|
-
.filter((call) => {
|
|
209
|
-
const callee = call.getExpression().getText();
|
|
210
|
-
return callee === 'effectService' || callee.endsWith('.effectService');
|
|
211
|
-
}),
|
|
212
|
-
].filter((candidate) => {
|
|
213
|
-
if (Node.isYieldExpression(candidate)) {
|
|
214
|
-
const expression = candidate.getExpression();
|
|
215
|
-
return (
|
|
216
|
-
expression !== undefined &&
|
|
217
|
-
Node.isIdentifier(expression) &&
|
|
218
|
-
services.has(expression.getText())
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
return true;
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
for (const candidate of candidates) {
|
|
225
|
-
const declaration = namedOperationDeclaration(candidate);
|
|
226
|
-
if (!declaration) continue;
|
|
227
|
-
const name = declaration.getName();
|
|
228
|
-
if (!name) continue;
|
|
229
|
-
const id = operationId(sourceFile.getFilePath(), name);
|
|
230
|
-
if (owners.has(id)) continue;
|
|
231
|
-
const node: DependencyGraphNode = {
|
|
232
|
-
id,
|
|
233
|
-
kind: 'effect-operation',
|
|
234
|
-
label: name,
|
|
235
|
-
filePath: sourceFile.getFilePath(),
|
|
236
|
-
line: declaration.getStartLineNumber(),
|
|
237
|
-
details: {
|
|
238
|
-
runtime: 'effect',
|
|
239
|
-
operationName: name,
|
|
240
|
-
resolution: 'static',
|
|
241
|
-
},
|
|
242
|
-
};
|
|
243
|
-
owners.set(id, {
|
|
244
|
-
name,
|
|
245
|
-
node,
|
|
246
|
-
start: declaration.getStart(),
|
|
247
|
-
end: declaration.getEnd(),
|
|
248
|
-
filePath: sourceFile.getFilePath(),
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return owners;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function namedOperationDeclaration(
|
|
257
|
-
node: Node,
|
|
258
|
-
): import('ts-morph').VariableDeclaration | import('ts-morph').FunctionDeclaration | undefined {
|
|
259
|
-
let current: Node | undefined = node.getParent();
|
|
260
|
-
while (current) {
|
|
261
|
-
if (Node.isVariableDeclaration(current) && current.getName()) {
|
|
262
|
-
const initializer = current.getInitializer();
|
|
263
|
-
if (
|
|
264
|
-
(Node.isCallExpression(initializer) &&
|
|
265
|
-
initializer.getExpression().getText() === 'Effect.gen') ||
|
|
266
|
-
initializer?.getDescendantsOfKind(SyntaxKind.CallExpression).some(
|
|
267
|
-
(call) => call.getExpression().getText() === 'Effect.gen',
|
|
268
|
-
)
|
|
269
|
-
) {
|
|
270
|
-
return current;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
if (Node.isFunctionDeclaration(current) && current.getName()) {
|
|
274
|
-
return current;
|
|
275
|
-
}
|
|
276
|
-
current = current.getParent();
|
|
277
|
-
}
|
|
278
|
-
return undefined;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
export type EffectLayerContribution = EffectGraphContribution;
|
|
282
|
-
|
|
283
|
-
const layerId = (filePath: string, name: string) =>
|
|
284
|
-
`effect-layer:${filePath}#${name}`;
|
|
285
|
-
|
|
286
|
-
/** Collects only Layer forms whose service and composition are statically visible. */
|
|
287
|
-
export function collectEffectLayers(
|
|
288
|
-
sourceFiles: readonly SourceFile[],
|
|
289
|
-
services: ReadonlyMap<string, EffectServiceInfo>,
|
|
290
|
-
): EffectLayerContribution {
|
|
291
|
-
const nodes = new Map<string, DependencyGraphNode>();
|
|
292
|
-
const edges = new Map<string, DependencyGraphEdge>();
|
|
293
|
-
const layerDeclarations = new Map<string, import('ts-morph').VariableDeclaration>();
|
|
294
|
-
const layerNames = new Map<string, string>();
|
|
295
|
-
|
|
296
|
-
const addNode = (node: DependencyGraphNode) => {
|
|
297
|
-
if (!nodes.has(node.id)) nodes.set(node.id, node);
|
|
298
|
-
};
|
|
299
|
-
const addEdge = (edge: DependencyGraphEdge) => {
|
|
300
|
-
const key = `${edge.from}:${edge.kind}:${edge.to}`;
|
|
301
|
-
if (!edges.has(key)) edges.set(key, edge);
|
|
302
|
-
};
|
|
303
|
-
const proof = (node: Node, pattern: string): DependencyGraphProof => ({
|
|
304
|
-
filePath: node.getSourceFile().getFilePath(),
|
|
305
|
-
line: node.getStartLineNumber(),
|
|
306
|
-
pattern,
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
for (const sourceFile of sourceFiles) {
|
|
310
|
-
for (const declaration of sourceFile.getVariableDeclarations()) {
|
|
311
|
-
const initializer = declaration.getInitializer();
|
|
312
|
-
if (!initializer || !containsLayerCall(initializer)) continue;
|
|
313
|
-
const name = declaration.getName();
|
|
314
|
-
const id = layerId(sourceFile.getFilePath(), name);
|
|
315
|
-
layerDeclarations.set(id, declaration);
|
|
316
|
-
layerNames.set(name, id);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
for (const [id, declaration] of layerDeclarations) {
|
|
321
|
-
const initializer = declaration.getInitializer();
|
|
322
|
-
if (!initializer) continue;
|
|
323
|
-
const provided: { name: string; node: Node }[] = [];
|
|
324
|
-
const required: { name: string; node: Node }[] = [];
|
|
325
|
-
const compositions: { id: string; node: Node }[] = [];
|
|
326
|
-
const calls = [
|
|
327
|
-
...(Node.isCallExpression(initializer) ? [initializer] : []),
|
|
328
|
-
...initializer.getDescendantsOfKind(SyntaxKind.CallExpression),
|
|
329
|
-
];
|
|
330
|
-
|
|
331
|
-
for (const call of calls) {
|
|
332
|
-
const callee = call.getExpression().getText();
|
|
333
|
-
const method = callee.match(/^Layer\.(succeed|sync|effect|scoped)$/)?.[1];
|
|
334
|
-
if (method) {
|
|
335
|
-
const argument = call.getArguments()[0];
|
|
336
|
-
if (argument && Node.isIdentifier(argument)) {
|
|
337
|
-
const service = services.get(argument.getText());
|
|
338
|
-
if (service) provided.push({ name: service.name, node: call });
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (callee === 'Layer.provide' || callee.endsWith('.provide')) {
|
|
342
|
-
const argument = call.getArguments()[0];
|
|
343
|
-
if (argument && Node.isIdentifier(argument)) {
|
|
344
|
-
const target = layerNames.get(argument.getText());
|
|
345
|
-
if (target) compositions.push({ id: target, node: argument });
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
for (const yielded of initializer.getDescendantsOfKind(SyntaxKind.YieldExpression)) {
|
|
351
|
-
const expression = yielded.getExpression();
|
|
352
|
-
if (!expression || !Node.isIdentifier(expression)) continue;
|
|
353
|
-
const service = services.get(expression.getText());
|
|
354
|
-
if (service && !provided.some((item) => item.name === service.name)) {
|
|
355
|
-
required.push({ name: service.name, node: yielded });
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const resolution =
|
|
360
|
-
provided.length > 0 &&
|
|
361
|
-
required.length === 0 &&
|
|
362
|
-
[...initializer.getDescendantsOfKind(SyntaxKind.CallExpression)].every(
|
|
363
|
-
(call) => !isUnknownLayerCall(call),
|
|
364
|
-
)
|
|
365
|
-
? 'complete'
|
|
366
|
-
: provided.length > 0 || required.length > 0
|
|
367
|
-
? 'partial'
|
|
368
|
-
: 'unknown';
|
|
369
|
-
const node: DependencyGraphNode = {
|
|
370
|
-
id,
|
|
371
|
-
kind: 'effect-layer',
|
|
372
|
-
label: declaration.getName(),
|
|
373
|
-
filePath: declaration.getSourceFile().getFilePath(),
|
|
374
|
-
line: declaration.getStartLineNumber(),
|
|
375
|
-
details: {
|
|
376
|
-
runtime: 'effect',
|
|
377
|
-
layerName: declaration.getName(),
|
|
378
|
-
providedServices: [...new Set(provided.map((item) => item.name))],
|
|
379
|
-
requiredServices: [...new Set(required.map((item) => item.name))],
|
|
380
|
-
compositions: [...new Set(compositions.map((item) => item.id))],
|
|
381
|
-
resolution,
|
|
382
|
-
},
|
|
383
|
-
};
|
|
384
|
-
addNode(node);
|
|
385
|
-
|
|
386
|
-
for (const item of provided) {
|
|
387
|
-
const target = effectServiceId(item.name);
|
|
388
|
-
addNode({
|
|
389
|
-
id: target,
|
|
390
|
-
kind: 'effect-service',
|
|
391
|
-
label: item.name,
|
|
392
|
-
filePath: services.get(item.name)?.filePath,
|
|
393
|
-
line: services.get(item.name)?.line,
|
|
394
|
-
details: {
|
|
395
|
-
runtime: 'effect',
|
|
396
|
-
tag: services.get(item.name)?.key ?? item.name,
|
|
397
|
-
serviceName: item.name,
|
|
398
|
-
},
|
|
399
|
-
});
|
|
400
|
-
addEdge({
|
|
401
|
-
from: target,
|
|
402
|
-
to: id,
|
|
403
|
-
kind: 'provided-by-layer',
|
|
404
|
-
evidence: 'ast',
|
|
405
|
-
details: { runtime: 'effect', service: item.name, resolution },
|
|
406
|
-
proof: proof(item.node, `Layer provides ${item.name}`),
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
for (const item of required) {
|
|
410
|
-
const target = effectServiceId(item.name);
|
|
411
|
-
addEdge({
|
|
412
|
-
from: id,
|
|
413
|
-
to: target,
|
|
414
|
-
kind: 'requires-service',
|
|
415
|
-
evidence: 'ast',
|
|
416
|
-
details: { runtime: 'effect', selection: 'whole-service' },
|
|
417
|
-
proof: proof(item.node, `yield* ${item.name}`),
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
for (const item of compositions) {
|
|
421
|
-
addEdge({
|
|
422
|
-
from: id,
|
|
423
|
-
to: item.id,
|
|
424
|
-
kind: 'composes-layer',
|
|
425
|
-
evidence: 'ast',
|
|
426
|
-
details: { runtime: 'effect' },
|
|
427
|
-
proof: proof(item.node, `Layer provides ${item.id}`),
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
return { nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function containsLayerCall(node: Node): boolean {
|
|
436
|
-
return [
|
|
437
|
-
...(Node.isCallExpression(node) ? [node] : []),
|
|
438
|
-
...node.getDescendantsOfKind(SyntaxKind.CallExpression),
|
|
439
|
-
].some((call) => call.getExpression().getText().startsWith('Layer.'));
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function isUnknownLayerCall(call: import('ts-morph').CallExpression): boolean {
|
|
443
|
-
const callee = call.getExpression().getText();
|
|
444
|
-
return callee.startsWith('Layer.') &&
|
|
445
|
-
!/^Layer\.(succeed|sync|effect|scoped|mergeAll|provide)$/.test(callee);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* Walks `effectService(Tag, selector?)` calls and produces the nodes and edges
|
|
450
|
-
* they justify. `ownerIdOf` maps a call site to the consumer node that should
|
|
451
|
-
* own the edge; a call outside any known consumer is skipped rather than
|
|
452
|
-
* attached to something arbitrary.
|
|
453
|
-
*/
|
|
454
|
-
export function collectEffectServiceUsage(
|
|
455
|
-
sourceFiles: readonly SourceFile[],
|
|
456
|
-
services: Map<string, EffectServiceInfo>,
|
|
457
|
-
ownerIdOf: (node: Node) => string | undefined,
|
|
458
|
-
includeBackendRelations = false,
|
|
459
|
-
): EffectGraphContribution {
|
|
460
|
-
const nodes = new Map<string, DependencyGraphNode>();
|
|
461
|
-
const edges = new Map<string, DependencyGraphEdge>();
|
|
462
|
-
|
|
463
|
-
const addNode = (node: DependencyGraphNode) => {
|
|
464
|
-
if (!nodes.has(node.id)) nodes.set(node.id, node);
|
|
465
|
-
};
|
|
466
|
-
const addEffectServiceNode = (service: EffectServiceInfo) => {
|
|
467
|
-
addNode({
|
|
468
|
-
id: effectServiceId(service.name),
|
|
469
|
-
kind: 'effect-service',
|
|
470
|
-
label: service.name,
|
|
471
|
-
filePath: service.filePath,
|
|
472
|
-
line: service.line,
|
|
473
|
-
details: {
|
|
474
|
-
runtime: 'effect',
|
|
475
|
-
tag: service.key,
|
|
476
|
-
serviceName: service.name,
|
|
477
|
-
},
|
|
478
|
-
});
|
|
479
|
-
};
|
|
480
|
-
const proofFor = (node: Node, pattern: string): DependencyGraphProof => ({
|
|
481
|
-
filePath: node.getSourceFile().getFilePath(),
|
|
482
|
-
line: node.getStartLineNumber(),
|
|
483
|
-
pattern,
|
|
484
|
-
});
|
|
485
|
-
const addEdge = (edge: DependencyGraphEdge) => {
|
|
486
|
-
const key = `${edge.from}:${edge.kind}:${edge.to}`;
|
|
487
|
-
if (!edges.has(key)) edges.set(key, edge);
|
|
488
|
-
};
|
|
489
|
-
|
|
490
|
-
for (const [, service] of services) {
|
|
491
|
-
addNode({
|
|
492
|
-
id: serviceId(service.name),
|
|
493
|
-
kind: 'service',
|
|
494
|
-
label: service.name,
|
|
495
|
-
filePath: service.filePath,
|
|
496
|
-
line: service.line,
|
|
497
|
-
details: { runtime: 'effect', tag: service.key },
|
|
498
|
-
});
|
|
499
|
-
addEffectServiceNode(service);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
for (const sourceFile of sourceFiles) {
|
|
503
|
-
for (const call of sourceFile.getDescendantsOfKind(
|
|
504
|
-
SyntaxKind.CallExpression,
|
|
505
|
-
)) {
|
|
506
|
-
const callee = call.getExpression().getText();
|
|
507
|
-
if (callee !== 'effectService' && !callee.endsWith('.effectService')) {
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const [tagArgument, selectorArgument] = call.getArguments();
|
|
512
|
-
if (!tagArgument) continue;
|
|
513
|
-
|
|
514
|
-
const service = services.get(tagArgument.getText());
|
|
515
|
-
if (!service) continue;
|
|
516
|
-
|
|
517
|
-
const ownerId = ownerIdOf(call);
|
|
518
|
-
if (!ownerId) continue;
|
|
519
|
-
|
|
520
|
-
const members = selectorArgument
|
|
521
|
-
? selectedMemberNames(selectorArgument)
|
|
522
|
-
: [];
|
|
523
|
-
|
|
524
|
-
if (members.length === 0) {
|
|
525
|
-
// Whole-service dependency: coarse edge, which is the honest drawing
|
|
526
|
-
// when the consumer took the whole thing.
|
|
527
|
-
addEdge({
|
|
528
|
-
from: ownerId,
|
|
529
|
-
to: serviceId(service.name),
|
|
530
|
-
kind: 'depends-on',
|
|
531
|
-
evidence: 'ast',
|
|
532
|
-
details: {
|
|
533
|
-
runtime: 'effect',
|
|
534
|
-
selection: 'whole-service',
|
|
535
|
-
...(isInsideLoader(call) ? { resourceRole: 'loader' } : {}),
|
|
536
|
-
},
|
|
537
|
-
});
|
|
538
|
-
if (includeBackendRelations) {
|
|
539
|
-
addEdge({
|
|
540
|
-
from: ownerId,
|
|
541
|
-
to: effectServiceId(service.name),
|
|
542
|
-
kind: 'requires-service',
|
|
543
|
-
evidence: 'ast',
|
|
544
|
-
details: {
|
|
545
|
-
runtime: 'effect',
|
|
546
|
-
selection: 'whole-service',
|
|
547
|
-
},
|
|
548
|
-
proof: proofFor(call, call.getExpression().getText()),
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
continue;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
for (const member of members) {
|
|
555
|
-
addNode({
|
|
556
|
-
id: memberId(service.name, member),
|
|
557
|
-
kind: 'property',
|
|
558
|
-
label: `${service.name}.${member}`,
|
|
559
|
-
filePath: service.filePath,
|
|
560
|
-
line: call.getStartLineNumber(),
|
|
561
|
-
details: { runtime: 'effect', member },
|
|
562
|
-
});
|
|
563
|
-
addEdge({
|
|
564
|
-
from: serviceId(service.name),
|
|
565
|
-
to: memberId(service.name, member),
|
|
566
|
-
kind: 'contains',
|
|
567
|
-
evidence: 'ast',
|
|
568
|
-
details: { member },
|
|
569
|
-
});
|
|
570
|
-
addEdge({
|
|
571
|
-
from: ownerId,
|
|
572
|
-
to: memberId(service.name, member),
|
|
573
|
-
kind: 'uses-property',
|
|
574
|
-
evidence: 'ast',
|
|
575
|
-
details: {
|
|
576
|
-
runtime: 'effect',
|
|
577
|
-
property: member,
|
|
578
|
-
...(isInsideLoader(call) ? { resourceRole: 'loader' } : {}),
|
|
579
|
-
},
|
|
580
|
-
});
|
|
581
|
-
if (includeBackendRelations) {
|
|
582
|
-
addEdge({
|
|
583
|
-
from: ownerId,
|
|
584
|
-
to: effectServiceId(service.name),
|
|
585
|
-
kind: 'requires-service',
|
|
586
|
-
evidence: 'ast',
|
|
587
|
-
details: {
|
|
588
|
-
runtime: 'effect',
|
|
589
|
-
selection: 'member',
|
|
590
|
-
member,
|
|
591
|
-
},
|
|
592
|
-
proof: proofFor(call, call.getExpression().getText()),
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
for (const yielded of includeBackendRelations
|
|
599
|
-
? sourceFile.getDescendantsOfKind(SyntaxKind.YieldExpression)
|
|
600
|
-
: []) {
|
|
601
|
-
const expression = yielded.getExpression();
|
|
602
|
-
if (!expression || !Node.isIdentifier(expression)) continue;
|
|
603
|
-
const service = services.get(expression.getText());
|
|
604
|
-
if (!service) continue;
|
|
605
|
-
const ownerId = ownerIdOf(yielded);
|
|
606
|
-
if (!ownerId) continue;
|
|
607
|
-
addEdge({
|
|
608
|
-
from: ownerId,
|
|
609
|
-
to: effectServiceId(service.name),
|
|
610
|
-
kind: 'requires-service',
|
|
611
|
-
evidence: 'ast',
|
|
612
|
-
details: { runtime: 'effect', selection: 'whole-service' },
|
|
613
|
-
proof: proofFor(yielded, `yield* ${service.name}`),
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
return { nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function isInsideLoader(node: Node): boolean {
|
|
622
|
-
let current: Node | undefined = node.getParent();
|
|
623
|
-
while (current) {
|
|
624
|
-
if (Node.isPropertyAssignment(current) && current.getName() === 'loader') {
|
|
625
|
-
return true;
|
|
626
|
-
}
|
|
627
|
-
current = current.getParent();
|
|
628
|
-
}
|
|
629
|
-
return false;
|
|
630
|
-
}
|