@zmdb/web 1.0.0-beta.1
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/LICENSE +674 -0
- package/README.md +49 -0
- package/dist/app/bridge.d.ts +12 -0
- package/dist/app/bridge.d.ts.map +1 -0
- package/dist/app/bridge.js +16 -0
- package/dist/app/bridge.js.map +1 -0
- package/dist/app/index.d.ts +23 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +47 -0
- package/dist/app/index.js.map +1 -0
- package/dist/bench/index.d.ts +68 -0
- package/dist/bench/index.d.ts.map +1 -0
- package/dist/bench/index.js +250 -0
- package/dist/bench/index.js.map +1 -0
- package/dist/compression/index.d.ts +13 -0
- package/dist/compression/index.d.ts.map +1 -0
- package/dist/compression/index.js +211 -0
- package/dist/compression/index.js.map +1 -0
- package/dist/context/index.d.ts +74 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +118 -0
- package/dist/context/index.js.map +1 -0
- package/dist/contract/__fixtures__/http-client.generated.d.ts +70 -0
- package/dist/contract/__fixtures__/http-client.generated.d.ts.map +1 -0
- package/dist/contract/__fixtures__/http-client.generated.js +86 -0
- package/dist/contract/__fixtures__/http-client.generated.js.map +1 -0
- package/dist/contract/__fixtures__/http-convergence.d.ts +396 -0
- package/dist/contract/__fixtures__/http-convergence.d.ts.map +1 -0
- package/dist/contract/__fixtures__/http-convergence.js +425 -0
- package/dist/contract/__fixtures__/http-convergence.js.map +1 -0
- package/dist/contract/compiler/client.d.ts +12 -0
- package/dist/contract/compiler/client.d.ts.map +1 -0
- package/dist/contract/compiler/client.js +1293 -0
- package/dist/contract/compiler/client.js.map +1 -0
- package/dist/contract/compiler/index.d.ts +18 -0
- package/dist/contract/compiler/index.d.ts.map +1 -0
- package/dist/contract/compiler/index.js +1058 -0
- package/dist/contract/compiler/index.js.map +1 -0
- package/dist/contract/index.d.ts +237 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +22 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/csrf/index.d.ts +15 -0
- package/dist/csrf/index.d.ts.map +1 -0
- package/dist/csrf/index.js +218 -0
- package/dist/csrf/index.js.map +1 -0
- package/dist/data/index.d.ts +35 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +58 -0
- package/dist/data/index.js.map +1 -0
- package/dist/devtools/index.d.ts +74 -0
- package/dist/devtools/index.d.ts.map +1 -0
- package/dist/devtools/index.js +552 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/dto-pipes/index.d.ts +45 -0
- package/dist/dto-pipes/index.d.ts.map +1 -0
- package/dist/dto-pipes/index.js +78 -0
- package/dist/dto-pipes/index.js.map +1 -0
- package/dist/gateways/index.d.ts +31 -0
- package/dist/gateways/index.d.ts.map +1 -0
- package/dist/gateways/index.js +138 -0
- package/dist/gateways/index.js.map +1 -0
- package/dist/health/index.d.ts +20 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +148 -0
- package/dist/health/index.js.map +1 -0
- package/dist/http-token.d.ts +2 -0
- package/dist/http-token.d.ts.map +1 -0
- package/dist/http-token.js +21 -0
- package/dist/http-token.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/index.d.ts +11 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +39 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/middleware/errors.d.ts +14 -0
- package/dist/middleware/errors.d.ts.map +1 -0
- package/dist/middleware/errors.js +18 -0
- package/dist/middleware/errors.js.map +1 -0
- package/dist/middleware/index.d.ts +46 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +64 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/modules/__fixtures__/large-graph.d.ts +239 -0
- package/dist/modules/__fixtures__/large-graph.d.ts.map +1 -0
- package/dist/modules/__fixtures__/large-graph.js +483 -0
- package/dist/modules/__fixtures__/large-graph.js.map +1 -0
- package/dist/openapi/__fixtures__/entities.d.ts +8 -0
- package/dist/openapi/__fixtures__/entities.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/entities.js +4 -0
- package/dist/openapi/__fixtures__/entities.js.map +1 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts +38 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.js +172 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.js.map +1 -0
- package/dist/openapi/__fixtures__/route-schemas.d.ts +2 -0
- package/dist/openapi/__fixtures__/route-schemas.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/route-schemas.js +17 -0
- package/dist/openapi/__fixtures__/route-schemas.js.map +1 -0
- package/dist/openapi/index.d.ts +61 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +314 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/pipeline/guards.d.ts +9 -0
- package/dist/pipeline/guards.d.ts.map +1 -0
- package/dist/pipeline/guards.js +11 -0
- package/dist/pipeline/guards.js.map +1 -0
- package/dist/pipeline/index.d.ts +141 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +1624 -0
- package/dist/pipeline/index.js.map +1 -0
- package/dist/routing/index.d.ts +40 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +162 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/static/index.d.ts +14 -0
- package/dist/static/index.d.ts.map +1 -0
- package/dist/static/index.js +348 -0
- package/dist/static/index.js.map +1 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/traced-router.d.ts +5 -0
- package/dist/traced-router.d.ts.map +1 -0
- package/dist/traced-router.js +6 -0
- package/dist/traced-router.js.map +1 -0
- package/dist/upload/index.d.ts +28 -0
- package/dist/upload/index.d.ts.map +1 -0
- package/dist/upload/index.js +410 -0
- package/dist/upload/index.js.map +1 -0
- package/dist/versioning/index.d.ts +35 -0
- package/dist/versioning/index.d.ts.map +1 -0
- package/dist/versioning/index.js +66 -0
- package/dist/versioning/index.js.map +1 -0
- package/dist/versioning/runtime.d.ts +5 -0
- package/dist/versioning/runtime.d.ts.map +1 -0
- package/dist/versioning/runtime.js +34 -0
- package/dist/versioning/runtime.js.map +1 -0
- package/package.json +143 -0
- package/src/app/bridge.ts +39 -0
- package/src/app/index.ts +76 -0
- package/src/bench/index.ts +329 -0
- package/src/compression/index.ts +255 -0
- package/src/context/index.ts +181 -0
- package/src/contract/__fixtures__/http-client.generated.ts +73 -0
- package/src/contract/__fixtures__/http-client.generated.ts.map +1 -0
- package/src/contract/__fixtures__/http-convergence.ts +564 -0
- package/src/contract/compiler/client.ts +1518 -0
- package/src/contract/compiler/index.ts +1457 -0
- package/src/contract/index.ts +266 -0
- package/src/csrf/index.ts +264 -0
- package/src/data/index.ts +65 -0
- package/src/devtools/index.ts +727 -0
- package/src/dto-pipes/index.ts +93 -0
- package/src/gateways/index.ts +180 -0
- package/src/health/index.ts +198 -0
- package/src/http-token.ts +22 -0
- package/src/index.ts +167 -0
- package/src/integrations/index.ts +50 -0
- package/src/middleware/errors.ts +17 -0
- package/src/middleware/index.ts +108 -0
- package/src/modules/__fixtures__/large-graph.ts +540 -0
- package/src/openapi/__fixtures__/entities.ts +19 -0
- package/src/openapi/__fixtures__/openapi-tools.fixture.ts +212 -0
- package/src/openapi/__fixtures__/route-schemas.ts +27 -0
- package/src/openapi/index.ts +483 -0
- package/src/pipeline/guards.ts +23 -0
- package/src/pipeline/index.ts +2041 -0
- package/src/routing/index.ts +201 -0
- package/src/static/index.ts +443 -0
- package/src/testing/index.ts +58 -0
- package/src/traced-router.ts +8 -0
- package/src/upload/index.ts +489 -0
- package/src/versioning/index.ts +106 -0
- package/src/versioning/runtime.ts +38 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
// @zmdb/web/devtools — an on-demand description of the declared module graph.
|
|
2
|
+
//
|
|
3
|
+
// This entry point is deliberately separate from the runtime package roots. It
|
|
4
|
+
// reconstructs the graph from decorator metadata only when called; WebApplication,
|
|
5
|
+
// CompiledModule and Container retain no inspector-specific state.
|
|
6
|
+
|
|
7
|
+
import type { Scope, Token } from '@zmdb/app/di';
|
|
8
|
+
import { injectionsOf } from '@zmdb/app/di';
|
|
9
|
+
import { moduleDefOf, type ModuleClass, type ModuleDef, type ProviderDef } from '@zmdb/app/modules';
|
|
10
|
+
|
|
11
|
+
import { getRoutes, type HttpMethod } from '../routing/index.js';
|
|
12
|
+
|
|
13
|
+
export interface ModuleNode {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly lazy: boolean;
|
|
17
|
+
readonly imports: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ProviderNode =
|
|
21
|
+
| {
|
|
22
|
+
readonly kind: 'value';
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly token: string;
|
|
25
|
+
readonly module: string;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
readonly kind: 'factory';
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly token: string;
|
|
31
|
+
readonly module: string;
|
|
32
|
+
readonly scope: Scope;
|
|
33
|
+
/** Factory bodies are opaque, so their dependency edges are unknowable. */
|
|
34
|
+
readonly dependencies: readonly string[] | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface RouteNode {
|
|
38
|
+
readonly method: HttpMethod;
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly handler: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ClassNode {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly module: string;
|
|
47
|
+
readonly routes: readonly RouteNode[];
|
|
48
|
+
readonly dependencies: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type FindingKind =
|
|
52
|
+
| 'cycle'
|
|
53
|
+
| 'unresolved-token'
|
|
54
|
+
| 'eager-depends-on-lazy'
|
|
55
|
+
| 'duplicate-provider'
|
|
56
|
+
| 'shadowed-route'
|
|
57
|
+
| 'duplicate-token-description'
|
|
58
|
+
| 'anonymous-class';
|
|
59
|
+
|
|
60
|
+
export interface Finding {
|
|
61
|
+
readonly kind: FindingKind;
|
|
62
|
+
readonly severity: 'error' | 'warning';
|
|
63
|
+
readonly message: string;
|
|
64
|
+
readonly subject: string;
|
|
65
|
+
readonly path?: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GraphDescription {
|
|
69
|
+
readonly modules: readonly ModuleNode[];
|
|
70
|
+
readonly providers: readonly ProviderNode[];
|
|
71
|
+
readonly controllers: readonly ClassNode[];
|
|
72
|
+
readonly findings: readonly Finding[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface GraphFilter {
|
|
76
|
+
readonly module?: string;
|
|
77
|
+
readonly token?: string;
|
|
78
|
+
readonly depth?: number;
|
|
79
|
+
readonly providers?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ImportEdge {
|
|
83
|
+
readonly module: ModuleClass;
|
|
84
|
+
readonly lazy: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ModuleRecord {
|
|
88
|
+
readonly module: ModuleClass;
|
|
89
|
+
readonly definition: ModuleDef | undefined;
|
|
90
|
+
readonly imports: readonly ImportEdge[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ProviderRecord {
|
|
94
|
+
readonly definition: ProviderDef;
|
|
95
|
+
readonly module: ModuleClass;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ControllerRecord {
|
|
99
|
+
readonly controller: abstract new (...args: never[]) => object;
|
|
100
|
+
readonly module: ModuleClass;
|
|
101
|
+
readonly injections: readonly { readonly field: string | symbol; readonly token: Token<unknown> }[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Projection {
|
|
105
|
+
readonly modules: ReadonlySet<string>;
|
|
106
|
+
readonly providers: ReadonlySet<string>;
|
|
107
|
+
readonly controllers: ReadonlySet<string>;
|
|
108
|
+
readonly details: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A visible marker that reverse factory edges cannot be derived from declarations. */
|
|
112
|
+
const UNKNOWN_FACTORY_DEPENDENTS = '<factory dependencies unknown>';
|
|
113
|
+
const DEFAULT_DEPTH = 2;
|
|
114
|
+
const MAX_UNFILTERED_PROVIDERS = 50;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reconstruct the declared graph without constructing a provider or controller.
|
|
118
|
+
*
|
|
119
|
+
* A cycle is returned as a finding instead of thrown, so this remains useful for
|
|
120
|
+
* the graphs that `compileModule` correctly refuses to boot.
|
|
121
|
+
*/
|
|
122
|
+
export function describeGraph(rootModule: ModuleClass): GraphDescription {
|
|
123
|
+
const records = new Map<ModuleClass, ModuleRecord>();
|
|
124
|
+
const order: ModuleClass[] = [];
|
|
125
|
+
const visited = new Set<ModuleClass>();
|
|
126
|
+
const active = new Set<ModuleClass>();
|
|
127
|
+
const stack: ModuleClass[] = [];
|
|
128
|
+
const cyclePaths: ModuleClass[][] = [];
|
|
129
|
+
|
|
130
|
+
const visit = (moduleClass: ModuleClass): void => {
|
|
131
|
+
if (active.has(moduleClass)) {
|
|
132
|
+
const repeatedAt = stack.indexOf(moduleClass);
|
|
133
|
+
cyclePaths.push([...stack.slice(repeatedAt), moduleClass]);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (visited.has(moduleClass)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
active.add(moduleClass);
|
|
141
|
+
stack.push(moduleClass);
|
|
142
|
+
const definition = moduleDefOf(moduleClass);
|
|
143
|
+
const imports = (definition?.imports ?? []).flatMap(declared => {
|
|
144
|
+
const edge = importEdgeOf(declared);
|
|
145
|
+
return edge === null ? [] : [edge];
|
|
146
|
+
});
|
|
147
|
+
records.set(moduleClass, { module: moduleClass, definition, imports });
|
|
148
|
+
order.push(moduleClass);
|
|
149
|
+
for (const imported of imports) {
|
|
150
|
+
visit(imported.module);
|
|
151
|
+
}
|
|
152
|
+
stack.pop();
|
|
153
|
+
active.delete(moduleClass);
|
|
154
|
+
visited.add(moduleClass);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
visit(rootModule);
|
|
158
|
+
|
|
159
|
+
const moduleIds = idsFor(order, 'module', module => module.name, '<anonymous>');
|
|
160
|
+
const eager = eagerModules(rootModule, records);
|
|
161
|
+
const providerRecords: ProviderRecord[] = [];
|
|
162
|
+
const controllerRecords: ControllerRecord[] = [];
|
|
163
|
+
|
|
164
|
+
for (const moduleClass of order) {
|
|
165
|
+
const definition = records.get(moduleClass)?.definition;
|
|
166
|
+
for (const provider of definition?.providers ?? []) {
|
|
167
|
+
providerRecords.push({ definition: provider, module: moduleClass });
|
|
168
|
+
}
|
|
169
|
+
for (const controller of definition?.controllers ?? []) {
|
|
170
|
+
controllerRecords.push({
|
|
171
|
+
controller,
|
|
172
|
+
module: moduleClass,
|
|
173
|
+
injections: injectionsOf(controller),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const allTokens = [
|
|
179
|
+
...providerRecords.map(record => record.definition.token),
|
|
180
|
+
...controllerRecords.flatMap(record => record.injections.map(injection => injection.token)),
|
|
181
|
+
];
|
|
182
|
+
const tokenIds = idsFor(allTokens, 'provider', token => token.description, '<anonymous-token>');
|
|
183
|
+
|
|
184
|
+
const modules: ModuleNode[] = order.map(moduleClass => ({
|
|
185
|
+
id: idOf(moduleIds, moduleClass),
|
|
186
|
+
name: moduleClass.name,
|
|
187
|
+
lazy: !eager.has(moduleClass),
|
|
188
|
+
imports: (records.get(moduleClass)?.imports ?? []).map(edge => idOf(moduleIds, edge.module)),
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
const providers: ProviderNode[] = providerRecords.map(record => {
|
|
192
|
+
const provider = record.definition;
|
|
193
|
+
const common = {
|
|
194
|
+
id: idOf(tokenIds, provider.token),
|
|
195
|
+
token: provider.token.description,
|
|
196
|
+
module: idOf(moduleIds, record.module),
|
|
197
|
+
};
|
|
198
|
+
if ('useValue' in provider) {
|
|
199
|
+
return { kind: 'value', ...common };
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
kind: 'factory',
|
|
203
|
+
...common,
|
|
204
|
+
scope: provider.scope ?? 'singleton',
|
|
205
|
+
dependencies: null,
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const controllers: ClassNode[] = controllerRecords.map(record => {
|
|
210
|
+
const moduleId = idOf(moduleIds, record.module);
|
|
211
|
+
return {
|
|
212
|
+
id: `controller:${moduleId.slice('module:'.length)}.${stableName(record.controller.name, '<anonymous>')}`,
|
|
213
|
+
name: record.controller.name,
|
|
214
|
+
module: moduleId,
|
|
215
|
+
routes: getRoutes(record.controller).map(route => ({
|
|
216
|
+
method: route.method,
|
|
217
|
+
path: route.path,
|
|
218
|
+
handler: route.handlerName,
|
|
219
|
+
})),
|
|
220
|
+
dependencies: record.injections.map(injection => idOf(tokenIds, injection.token)),
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const findings: Finding[] = [];
|
|
225
|
+
const seenCycles = new Set<string>();
|
|
226
|
+
for (const path of cyclePaths) {
|
|
227
|
+
const ids = path.map(moduleClass => idOf(moduleIds, moduleClass));
|
|
228
|
+
const key = ids.join('\0');
|
|
229
|
+
if (seenCycles.has(key)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
seenCycles.add(key);
|
|
233
|
+
findings.push({
|
|
234
|
+
kind: 'cycle',
|
|
235
|
+
severity: 'error',
|
|
236
|
+
message: `Import cycle: ${ids.join(' -> ')}`,
|
|
237
|
+
subject: ids[0] ?? 'module:<unknown>',
|
|
238
|
+
path: ids,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const providersByToken = groupBy(providerRecords, record => record.definition.token);
|
|
243
|
+
for (const [token, registrations] of providersByToken) {
|
|
244
|
+
const modulesForToken = unique(registrations.map(record => record.module));
|
|
245
|
+
if (modulesForToken.length > 1) {
|
|
246
|
+
findings.push({
|
|
247
|
+
kind: 'duplicate-provider',
|
|
248
|
+
severity: 'error',
|
|
249
|
+
message:
|
|
250
|
+
`Token "${token.description}" is registered by ` +
|
|
251
|
+
modulesForToken.map(moduleClass => moduleClass.name || '<anonymous>').join(' and '),
|
|
252
|
+
subject: idOf(tokenIds, token),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const record of controllerRecords) {
|
|
258
|
+
const controllerId = controllerIdOf(controllers, record, moduleIds);
|
|
259
|
+
for (const injection of record.injections) {
|
|
260
|
+
const registrations = providersByToken.get(injection.token) ?? [];
|
|
261
|
+
if (registrations.length === 0) {
|
|
262
|
+
findings.push({
|
|
263
|
+
kind: 'unresolved-token',
|
|
264
|
+
severity: 'error',
|
|
265
|
+
message:
|
|
266
|
+
`${record.controller.name || '<anonymous>'}.${String(injection.field)} injects ` +
|
|
267
|
+
`"${injection.token.description}", but no module registers it`,
|
|
268
|
+
subject: controllerId,
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (eager.has(record.module) && registrations.every(registration => !eager.has(registration.module))) {
|
|
273
|
+
findings.push({
|
|
274
|
+
kind: 'eager-depends-on-lazy',
|
|
275
|
+
severity: 'error',
|
|
276
|
+
message:
|
|
277
|
+
`Eager controller ${record.controller.name || '<anonymous>'} injects lazy-only token ` +
|
|
278
|
+
`"${injection.token.description}"`,
|
|
279
|
+
subject: controllerId,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const routes = new Map<string, string>();
|
|
286
|
+
for (const controller of controllers) {
|
|
287
|
+
for (const route of controller.routes) {
|
|
288
|
+
const key = `${route.method}\0${route.path}`;
|
|
289
|
+
const first = routes.get(key);
|
|
290
|
+
if (first === undefined) {
|
|
291
|
+
routes.set(key, controller.id);
|
|
292
|
+
} else {
|
|
293
|
+
findings.push({
|
|
294
|
+
kind: 'shadowed-route',
|
|
295
|
+
severity: 'error',
|
|
296
|
+
message: `${route.method} ${route.path} on ${controller.id} is shadowed by ${first}`,
|
|
297
|
+
subject: controller.id,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const tokensByDescription = groupBy(unique(allTokens), token => token.description);
|
|
304
|
+
for (const [description, tokens] of tokensByDescription) {
|
|
305
|
+
if (tokens.length < 2) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
for (const token of tokens) {
|
|
309
|
+
findings.push({
|
|
310
|
+
kind: 'duplicate-token-description',
|
|
311
|
+
severity: 'warning',
|
|
312
|
+
message: `Distinct tokens share the description "${description}"`,
|
|
313
|
+
subject: idOf(tokenIds, token),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const moduleClass of order) {
|
|
319
|
+
if (moduleClass.name.length === 0) {
|
|
320
|
+
findings.push({
|
|
321
|
+
kind: 'anonymous-class',
|
|
322
|
+
severity: 'warning',
|
|
323
|
+
message: 'A module has no stable class name',
|
|
324
|
+
subject: idOf(moduleIds, moduleClass),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const controller of controllers) {
|
|
329
|
+
if (controller.name.length === 0) {
|
|
330
|
+
findings.push({
|
|
331
|
+
kind: 'anonymous-class',
|
|
332
|
+
severity: 'warning',
|
|
333
|
+
message: 'A controller has no stable class name',
|
|
334
|
+
subject: controller.id,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for (const token of unique(allTokens)) {
|
|
339
|
+
if (token.description.length === 0) {
|
|
340
|
+
findings.push({
|
|
341
|
+
kind: 'anonymous-class',
|
|
342
|
+
severity: 'warning',
|
|
343
|
+
message: 'A provider token has no stable description',
|
|
344
|
+
subject: idOf(tokenIds, token),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { modules, providers, controllers, findings };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Return known direct reverse edges.
|
|
354
|
+
*
|
|
355
|
+
* Factory provider bodies are opaque. For a provider query, the sentinel keeps
|
|
356
|
+
* that incompleteness visible instead of returning an unsafe "nothing else".
|
|
357
|
+
*/
|
|
358
|
+
export function dependentsOf(graph: GraphDescription, id: string): readonly string[] {
|
|
359
|
+
const known = [
|
|
360
|
+
...graph.providers.flatMap(provider =>
|
|
361
|
+
provider.kind === 'factory' && provider.dependencies?.includes(id) ? [provider.id] : [],
|
|
362
|
+
),
|
|
363
|
+
...graph.controllers.flatMap(controller => (controller.dependencies.includes(id) ? [controller.id] : [])),
|
|
364
|
+
];
|
|
365
|
+
const asksAboutProvider = graph.providers.some(provider => provider.id === id);
|
|
366
|
+
const hasOpaqueFactory = graph.providers.some(
|
|
367
|
+
provider => provider.kind === 'factory' && provider.dependencies === null,
|
|
368
|
+
);
|
|
369
|
+
return asksAboutProvider && hasOpaqueFactory ? [...unique(known), UNKNOWN_FACTORY_DEPENDENTS] : unique(known);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Render a deterministic, readable module tree. */
|
|
373
|
+
export function renderTree(graph: GraphDescription, filter?: GraphFilter): string {
|
|
374
|
+
const projection = project(graph, filter);
|
|
375
|
+
const lines: string[] = [];
|
|
376
|
+
|
|
377
|
+
for (const module of graph.modules) {
|
|
378
|
+
if (!projection.modules.has(module.id)) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
lines.push(`${module.name || '<anonymous>'}${module.lazy ? ' [lazy]' : ''}`);
|
|
382
|
+
const imports = module.imports.filter(id => projection.modules.has(id)).map(id => displayId(id));
|
|
383
|
+
if (imports.length > 0) {
|
|
384
|
+
lines.push(` imports: ${imports.join(', ')}`);
|
|
385
|
+
}
|
|
386
|
+
if (!projection.details) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
for (const provider of graph.providers) {
|
|
390
|
+
if (provider.module !== module.id || !projection.providers.has(provider.id)) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const detail =
|
|
394
|
+
provider.kind === 'value'
|
|
395
|
+
? 'value'
|
|
396
|
+
: `${provider.scope}; ${provider.dependencies === null ? 'dependencies unknown' : 'no dependencies'}`;
|
|
397
|
+
lines.push(` provider ${provider.token} (${detail})`);
|
|
398
|
+
}
|
|
399
|
+
for (const controller of graph.controllers) {
|
|
400
|
+
if (controller.module !== module.id || !projection.controllers.has(controller.id)) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
lines.push(` controller ${controller.name || '<anonymous>'}`);
|
|
404
|
+
for (const route of controller.routes) {
|
|
405
|
+
lines.push(
|
|
406
|
+
` ${route.method.padEnd(6)} ${route.path.padEnd(30)} ${controller.name || '<anonymous>'}.${route.handler}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (graph.findings.length > 0) {
|
|
413
|
+
lines.push('findings');
|
|
414
|
+
for (const finding of graph.findings) {
|
|
415
|
+
lines.push(` ${finding.severity.toUpperCase()} ${finding.kind}: ${finding.message}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return `${lines.join('\n')}\n`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Render Graphviz DOT with every id and label quoted. */
|
|
422
|
+
export function renderDot(graph: GraphDescription, filter?: GraphFilter): string {
|
|
423
|
+
const projection = project(graph, filter);
|
|
424
|
+
const lines = ['digraph zmdb {', ' rankdir="LR";'];
|
|
425
|
+
|
|
426
|
+
for (const module of graph.modules) {
|
|
427
|
+
if (projection.modules.has(module.id)) {
|
|
428
|
+
lines.push(
|
|
429
|
+
` ${quote(module.id)} [shape="box", label=${quote(`${module.name || '<anonymous>'}${module.lazy ? ' [lazy]' : ''}`)}];`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
for (const module of graph.modules) {
|
|
434
|
+
if (!projection.modules.has(module.id)) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
for (const imported of module.imports) {
|
|
438
|
+
if (projection.modules.has(imported)) {
|
|
439
|
+
lines.push(` ${quote(module.id)} -> ${quote(imported)} [label="imports"];`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (projection.details) {
|
|
445
|
+
for (const provider of graph.providers) {
|
|
446
|
+
if (!projection.providers.has(provider.id)) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const unknown = provider.kind === 'factory' && provider.dependencies === null;
|
|
450
|
+
const attributes = [
|
|
451
|
+
'shape="ellipse"',
|
|
452
|
+
`label=${quote(
|
|
453
|
+
provider.kind === 'value'
|
|
454
|
+
? `${provider.token}\nvalue`
|
|
455
|
+
: `${provider.token}\n${provider.scope}${unknown ? '\ndependencies unknown' : ''}`,
|
|
456
|
+
)}`,
|
|
457
|
+
...(unknown ? ['style="dashed"'] : []),
|
|
458
|
+
];
|
|
459
|
+
lines.push(` ${quote(provider.id)} [${attributes.join(', ')}];`);
|
|
460
|
+
if (projection.modules.has(provider.module)) {
|
|
461
|
+
lines.push(` ${quote(provider.module)} -> ${quote(provider.id)} [label="provides"];`);
|
|
462
|
+
}
|
|
463
|
+
if (provider.kind === 'factory' && provider.dependencies !== null) {
|
|
464
|
+
for (const dependency of provider.dependencies) {
|
|
465
|
+
if (projection.providers.has(dependency)) {
|
|
466
|
+
lines.push(` ${quote(provider.id)} -> ${quote(dependency)} [label="depends on"];`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const controller of graph.controllers) {
|
|
473
|
+
if (!projection.controllers.has(controller.id)) {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const routes = controller.routes.map(route => `${route.method} ${route.path} ${route.handler}`).join('\n');
|
|
477
|
+
lines.push(
|
|
478
|
+
` ${quote(controller.id)} [shape="component", label=${quote(
|
|
479
|
+
routes.length === 0 ? controller.name || '<anonymous>' : `${controller.name || '<anonymous>'}\n${routes}`,
|
|
480
|
+
)}];`,
|
|
481
|
+
);
|
|
482
|
+
if (projection.modules.has(controller.module)) {
|
|
483
|
+
lines.push(` ${quote(controller.module)} -> ${quote(controller.id)} [label="controller"];`);
|
|
484
|
+
}
|
|
485
|
+
for (const dependency of controller.dependencies) {
|
|
486
|
+
if (projection.providers.has(dependency)) {
|
|
487
|
+
lines.push(` ${quote(controller.id)} -> ${quote(dependency)} [label="injects"];`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
lines.push('}');
|
|
494
|
+
return `${lines.join('\n')}\n`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function importEdgeOf(declared: unknown): ImportEdge | null {
|
|
498
|
+
if (isModuleClass(declared)) {
|
|
499
|
+
return { module: declared, lazy: false };
|
|
500
|
+
}
|
|
501
|
+
if (typeof declared !== 'object' || declared === null) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
const candidate: { kind?: unknown; module?: unknown } = declared;
|
|
505
|
+
return candidate.kind === 'lazy' && isModuleClass(candidate.module) ? { module: candidate.module, lazy: true } : null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function isModuleClass(value: unknown): value is ModuleClass {
|
|
509
|
+
return typeof value === 'function';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function eagerModules(root: ModuleClass, records: ReadonlyMap<ModuleClass, ModuleRecord>): ReadonlySet<ModuleClass> {
|
|
513
|
+
const eager = new Set<ModuleClass>();
|
|
514
|
+
const mark = (moduleClass: ModuleClass): void => {
|
|
515
|
+
if (eager.has(moduleClass)) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
eager.add(moduleClass);
|
|
519
|
+
for (const imported of records.get(moduleClass)?.imports ?? []) {
|
|
520
|
+
if (!imported.lazy) {
|
|
521
|
+
mark(imported.module);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
mark(root);
|
|
526
|
+
return eager;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function idsFor<T extends object>(
|
|
530
|
+
values: readonly T[],
|
|
531
|
+
namespace: string,
|
|
532
|
+
describe: (value: T) => string,
|
|
533
|
+
anonymous: string,
|
|
534
|
+
): ReadonlyMap<T, string> {
|
|
535
|
+
const distinct = unique(values);
|
|
536
|
+
const groups = groupBy(distinct, value => stableName(describe(value), anonymous));
|
|
537
|
+
const ids = new Map<T, string>();
|
|
538
|
+
const used = new Set<string>();
|
|
539
|
+
for (const [description, group] of groups) {
|
|
540
|
+
group.forEach((value, index) => {
|
|
541
|
+
const base = `${namespace}:${description}${group.length > 1 ? `#${String(index + 1)}` : ''}`;
|
|
542
|
+
let candidate = base;
|
|
543
|
+
let disambiguator = 2;
|
|
544
|
+
while (used.has(candidate)) {
|
|
545
|
+
candidate = `${base}#${String(disambiguator)}`;
|
|
546
|
+
disambiguator += 1;
|
|
547
|
+
}
|
|
548
|
+
used.add(candidate);
|
|
549
|
+
ids.set(value, candidate);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return ids;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function idOf<T extends object>(ids: ReadonlyMap<T, string>, value: T): string {
|
|
556
|
+
return ids.get(value) ?? '<unknown>';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function controllerIdOf(
|
|
560
|
+
controllers: readonly ClassNode[],
|
|
561
|
+
record: ControllerRecord,
|
|
562
|
+
moduleIds: ReadonlyMap<ModuleClass, string>,
|
|
563
|
+
): string {
|
|
564
|
+
const expected = `controller:${idOf(moduleIds, record.module).slice('module:'.length)}.${stableName(
|
|
565
|
+
record.controller.name,
|
|
566
|
+
'<anonymous>',
|
|
567
|
+
)}`;
|
|
568
|
+
return controllers.find(controller => controller.id === expected)?.id ?? expected;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function stableName(name: string, fallback: string): string {
|
|
572
|
+
return name.length === 0 ? fallback : name;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function groupBy<K, V>(values: readonly V[], keyOf: (value: V) => K): Map<K, V[]> {
|
|
576
|
+
const groups = new Map<K, V[]>();
|
|
577
|
+
for (const value of values) {
|
|
578
|
+
const key = keyOf(value);
|
|
579
|
+
const group = groups.get(key);
|
|
580
|
+
if (group === undefined) {
|
|
581
|
+
groups.set(key, [value]);
|
|
582
|
+
} else {
|
|
583
|
+
group.push(value);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return groups;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function unique<T>(values: readonly T[]): T[] {
|
|
590
|
+
return [...new Set(values)];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function project(graph: GraphDescription, filter: GraphFilter = {}): Projection {
|
|
594
|
+
const depth = filter.depth ?? DEFAULT_DEPTH;
|
|
595
|
+
if (!Number.isInteger(depth) || depth < 0) {
|
|
596
|
+
throw new Error(`depth must be a non-negative integer, received ${String(depth)}`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const details = filter.providers === true || filter.module !== undefined || filter.token !== undefined;
|
|
600
|
+
if (
|
|
601
|
+
details &&
|
|
602
|
+
filter.module === undefined &&
|
|
603
|
+
filter.token === undefined &&
|
|
604
|
+
graph.providers.length > MAX_UNFILTERED_PROVIDERS
|
|
605
|
+
) {
|
|
606
|
+
const moduleNames = graph.modules.map(module => module.name || '<anonymous>').join(', ');
|
|
607
|
+
throw new Error(
|
|
608
|
+
`refusing an unfiltered graph with ${String(graph.providers.length)} provider nodes; ` +
|
|
609
|
+
`filter with module or token (${moduleNames})`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const modules = new Set<string>();
|
|
614
|
+
const providers = new Set<string>();
|
|
615
|
+
const controllers = new Set<string>();
|
|
616
|
+
|
|
617
|
+
if (filter.module !== undefined) {
|
|
618
|
+
const root = graph.modules.find(module => module.name === filter.module || module.id === filter.module);
|
|
619
|
+
if (root === undefined) {
|
|
620
|
+
throw new Error(`no module named "${filter.module}"`);
|
|
621
|
+
}
|
|
622
|
+
const queue: { readonly id: string; readonly distance: number }[] = [{ id: root.id, distance: 0 }];
|
|
623
|
+
while (queue.length > 0) {
|
|
624
|
+
const current = queue.shift();
|
|
625
|
+
if (current === undefined || modules.has(current.id) || current.distance > depth) {
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
modules.add(current.id);
|
|
629
|
+
const node = graph.modules.find(module => module.id === current.id);
|
|
630
|
+
for (const imported of node?.imports ?? []) {
|
|
631
|
+
queue.push({ id: imported, distance: current.distance + 1 });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} else if (filter.token === undefined) {
|
|
635
|
+
for (const module of graph.modules) {
|
|
636
|
+
modules.add(module.id);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (filter.token !== undefined) {
|
|
641
|
+
const seeds = graph.providers.filter(provider => provider.token === filter.token || provider.id === filter.token);
|
|
642
|
+
if (seeds.length === 0) {
|
|
643
|
+
throw new Error(`no token named "${filter.token}"`);
|
|
644
|
+
}
|
|
645
|
+
const adjacency = dependencyAdjacency(graph);
|
|
646
|
+
const queue: { readonly id: string; readonly distance: number }[] = seeds.map(provider => ({
|
|
647
|
+
id: provider.id,
|
|
648
|
+
distance: 0,
|
|
649
|
+
}));
|
|
650
|
+
const seen = new Set<string>();
|
|
651
|
+
while (queue.length > 0) {
|
|
652
|
+
const current = queue.shift();
|
|
653
|
+
if (current === undefined || seen.has(current.id) || current.distance > depth) {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
seen.add(current.id);
|
|
657
|
+
const provider = graph.providers.find(node => node.id === current.id);
|
|
658
|
+
if (provider !== undefined) {
|
|
659
|
+
providers.add(provider.id);
|
|
660
|
+
modules.add(provider.module);
|
|
661
|
+
}
|
|
662
|
+
const controller = graph.controllers.find(node => node.id === current.id);
|
|
663
|
+
if (controller !== undefined) {
|
|
664
|
+
controllers.add(controller.id);
|
|
665
|
+
modules.add(controller.module);
|
|
666
|
+
}
|
|
667
|
+
for (const neighbour of adjacency.get(current.id) ?? []) {
|
|
668
|
+
queue.push({ id: neighbour, distance: current.distance + 1 });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (details && filter.token === undefined) {
|
|
674
|
+
for (const provider of graph.providers) {
|
|
675
|
+
if (modules.has(provider.module)) {
|
|
676
|
+
providers.add(provider.id);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
for (const controller of graph.controllers) {
|
|
680
|
+
if (modules.has(controller.module)) {
|
|
681
|
+
controllers.add(controller.id);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return { modules, providers, controllers, details };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function dependencyAdjacency(graph: GraphDescription): ReadonlyMap<string, ReadonlySet<string>> {
|
|
690
|
+
const adjacency = new Map<string, Set<string>>();
|
|
691
|
+
const connect = (left: string, right: string): void => {
|
|
692
|
+
const fromLeft = adjacency.get(left);
|
|
693
|
+
if (fromLeft === undefined) {
|
|
694
|
+
adjacency.set(left, new Set([right]));
|
|
695
|
+
} else {
|
|
696
|
+
fromLeft.add(right);
|
|
697
|
+
}
|
|
698
|
+
const fromRight = adjacency.get(right);
|
|
699
|
+
if (fromRight === undefined) {
|
|
700
|
+
adjacency.set(right, new Set([left]));
|
|
701
|
+
} else {
|
|
702
|
+
fromRight.add(left);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
for (const provider of graph.providers) {
|
|
706
|
+
if (provider.kind === 'factory' && provider.dependencies !== null) {
|
|
707
|
+
for (const dependency of provider.dependencies) {
|
|
708
|
+
connect(provider.id, dependency);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
for (const controller of graph.controllers) {
|
|
713
|
+
for (const dependency of controller.dependencies) {
|
|
714
|
+
connect(controller.id, dependency);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return adjacency;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function displayId(id: string): string {
|
|
721
|
+
const separator = id.indexOf(':');
|
|
722
|
+
return separator === -1 ? id : id.slice(separator + 1);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function quote(value: string): string {
|
|
726
|
+
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`;
|
|
727
|
+
}
|