proteum 2.1.9 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex/environments/environment.toml +11 -0
- package/AGENTS.md +25 -11
- package/README.md +19 -9
- package/agents/project/AGENTS.md +165 -120
- package/agents/project/CODING_STYLE.md +1 -1
- package/agents/project/app-root/AGENTS.md +16 -0
- package/agents/project/client/AGENTS.md +5 -5
- package/agents/project/client/pages/AGENTS.md +13 -13
- package/agents/project/diagnostics.md +19 -10
- package/agents/project/optimizations.md +5 -6
- package/agents/project/root/AGENTS.md +295 -0
- package/agents/project/server/routes/AGENTS.md +2 -2
- package/agents/project/server/services/AGENTS.md +4 -2
- package/agents/project/tests/AGENTS.md +2 -2
- package/cli/app/index.ts +31 -7
- package/cli/commands/configure.ts +226 -0
- package/cli/commands/dev.ts +0 -2
- package/cli/commands/diagnose.ts +33 -1
- package/cli/commands/explain.ts +1 -1
- package/cli/commands/migrate.ts +51 -0
- package/cli/commands/orient.ts +169 -0
- package/cli/commands/perf.ts +8 -1
- package/cli/commands/verify.ts +1003 -49
- package/cli/compiler/artifacts/manifest.ts +4 -4
- package/cli/compiler/artifacts/routing.ts +2 -2
- package/cli/compiler/artifacts/services.ts +12 -3
- package/cli/compiler/client/index.ts +65 -19
- package/cli/compiler/common/files/style.ts +47 -2
- package/cli/compiler/common/generatedRouteModules.ts +31 -38
- package/cli/compiler/common/index.ts +10 -0
- package/cli/compiler/common/proteumManifest.ts +1 -0
- package/cli/compiler/server/index.ts +34 -9
- package/cli/context.ts +6 -1
- package/cli/index.ts +7 -8
- package/cli/migrate/pageContract.ts +516 -0
- package/cli/paths.ts +47 -6
- package/cli/presentation/commands.ts +100 -10
- package/cli/presentation/devSession.ts +4 -6
- package/cli/presentation/help.ts +2 -2
- package/cli/presentation/ink.ts +10 -5
- package/cli/presentation/welcome.ts +2 -4
- package/cli/runtime/commands.ts +94 -1
- package/cli/scaffold/index.ts +2 -2
- package/cli/scaffold/templates.ts +4 -2
- package/cli/utils/agents.ts +273 -58
- package/client/dev/profiler/index.tsx +3 -2
- package/client/router.ts +10 -2
- package/client/services/router/index.tsx +6 -22
- package/common/dev/connect.ts +20 -4
- package/common/dev/console.ts +7 -0
- package/common/dev/contractsDoctor.ts +354 -0
- package/common/dev/diagnostics.ts +10 -7
- package/common/dev/inspection.ts +830 -38
- package/common/dev/performance.ts +19 -5
- package/common/dev/profiler.ts +1 -0
- package/common/dev/proteumManifest.ts +5 -4
- package/common/dev/requestTrace.ts +12 -1
- package/common/router/contracts.ts +8 -11
- package/common/router/index.ts +2 -2
- package/common/router/pageData.ts +72 -0
- package/common/router/register.ts +10 -46
- package/common/router/response/page.ts +28 -16
- package/docs/dev-sessions.md +8 -4
- package/docs/diagnostics.md +77 -11
- package/docs/migrate-from-2.1.3.md +388 -0
- package/docs/request-tracing.md +25 -6
- package/package.json +6 -1
- package/scripts/update-codex-agents.ts +2 -2
- package/server/app/container/console/index.ts +11 -1
- package/server/app/container/trace/index.ts +117 -0
- package/server/app/devDiagnostics.ts +1 -1
- package/server/app/index.ts +5 -1
- package/server/services/auth/index.ts +9 -0
- package/server/services/router/index.ts +64 -14
- package/server/services/router/request/api.ts +7 -1
- package/server/services/router/response/index.ts +8 -28
- package/types/global/vendors.d.ts +12 -0
- package/types/vendors.d.ts +12 -0
- package/common/router/pageSetup.ts +0 -51
package/common/dev/inspection.ts
CHANGED
|
@@ -18,6 +18,8 @@ import type { TRequestTrace, TTraceEvent } from './requestTrace';
|
|
|
18
18
|
----------------------------------*/
|
|
19
19
|
|
|
20
20
|
export type TOwnerKind = 'route' | 'controller' | 'command' | 'service' | 'layout' | 'diagnostic';
|
|
21
|
+
export type TOwnerScopeLabel = 'local' | 'generated' | 'connected' | 'framework';
|
|
22
|
+
export type TChainKind = 'route' | 'controller' | 'service' | 'cache' | 'connected' | 'sql';
|
|
21
23
|
|
|
22
24
|
export type TOwnerSource = {
|
|
23
25
|
filepath: string;
|
|
@@ -31,6 +33,8 @@ export type TExplainOwnerMatch = {
|
|
|
31
33
|
kind: TOwnerKind;
|
|
32
34
|
label: string;
|
|
33
35
|
matchedOn: string[];
|
|
36
|
+
originHint: string;
|
|
37
|
+
scopeLabel: TOwnerScopeLabel;
|
|
34
38
|
score: number;
|
|
35
39
|
source: TOwnerSource;
|
|
36
40
|
};
|
|
@@ -55,6 +59,65 @@ export type TTraceAttributionResponse = {
|
|
|
55
59
|
sqlQueries: TTraceAttributionItem[];
|
|
56
60
|
};
|
|
57
61
|
|
|
62
|
+
export type TOrientGuidance = {
|
|
63
|
+
agents: string;
|
|
64
|
+
diagnostics: string;
|
|
65
|
+
optimizations: string;
|
|
66
|
+
codingStyle: string;
|
|
67
|
+
areaAgents: string[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type TOrientConnected = {
|
|
71
|
+
imports: Array<{
|
|
72
|
+
namespace: string;
|
|
73
|
+
clientAccessor: string;
|
|
74
|
+
httpPath: string;
|
|
75
|
+
filepath: string;
|
|
76
|
+
scopeLabel: TOwnerScopeLabel;
|
|
77
|
+
originHint: string;
|
|
78
|
+
}>;
|
|
79
|
+
producers: Array<{
|
|
80
|
+
namespace: string;
|
|
81
|
+
identityIdentifier?: string;
|
|
82
|
+
identityName?: string;
|
|
83
|
+
sourceKind?: string;
|
|
84
|
+
sourceValue?: string;
|
|
85
|
+
urlInternal?: string;
|
|
86
|
+
controllerCount: number;
|
|
87
|
+
cachedContractFilepath?: string;
|
|
88
|
+
typingMode?: string;
|
|
89
|
+
}>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type TOrientationNextStep = {
|
|
93
|
+
label: string;
|
|
94
|
+
command: string;
|
|
95
|
+
reason: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type TOrientResponse = {
|
|
99
|
+
query: string;
|
|
100
|
+
normalizedQuery: string;
|
|
101
|
+
app: {
|
|
102
|
+
appRoot: string;
|
|
103
|
+
repoRoot: string;
|
|
104
|
+
identifier: string;
|
|
105
|
+
routerPort?: number;
|
|
106
|
+
};
|
|
107
|
+
guidance: TOrientGuidance;
|
|
108
|
+
owner: TExplainOwnerResponse;
|
|
109
|
+
connected: TOrientConnected;
|
|
110
|
+
nextSteps: TOrientationNextStep[];
|
|
111
|
+
warnings: string[];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type TDiagnoseChainItem = {
|
|
115
|
+
kind: TChainKind;
|
|
116
|
+
label: string;
|
|
117
|
+
source?: TOwnerSource;
|
|
118
|
+
details: string[];
|
|
119
|
+
};
|
|
120
|
+
|
|
58
121
|
export type TDiagnoseSuspect = {
|
|
59
122
|
filepath: string;
|
|
60
123
|
label: string;
|
|
@@ -70,19 +133,23 @@ export type TDiagnoseResponse = {
|
|
|
70
133
|
doctor: TDoctorResponse;
|
|
71
134
|
explainSummary: string[];
|
|
72
135
|
owner: TExplainOwnerResponse;
|
|
136
|
+
orientation?: Pick<TOrientResponse, 'guidance' | 'connected' | 'nextSteps'>;
|
|
137
|
+
chain?: TDiagnoseChainItem[];
|
|
73
138
|
query: string;
|
|
74
139
|
request?: TRequestTrace;
|
|
75
140
|
serverLogs: TDevConsoleLogsResponse;
|
|
76
141
|
suspects: TDiagnoseSuspect[];
|
|
77
142
|
};
|
|
78
143
|
|
|
79
|
-
type TManifestEntry =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
144
|
+
type TManifestEntry = {
|
|
145
|
+
details: string[];
|
|
146
|
+
kind: TOwnerKind;
|
|
147
|
+
label: string;
|
|
148
|
+
searchTerms: string[];
|
|
149
|
+
source: TOwnerSource;
|
|
150
|
+
originHint: string;
|
|
151
|
+
scopeLabel: TOwnerScopeLabel;
|
|
152
|
+
};
|
|
86
153
|
|
|
87
154
|
type TSuspectAccumulator = {
|
|
88
155
|
column?: number;
|
|
@@ -98,12 +165,102 @@ type TSuspectAccumulator = {
|
|
|
98
165
|
----------------------------------*/
|
|
99
166
|
|
|
100
167
|
const normalizeText = (value: string) => value.trim().replace(/\\/g, '/').toLowerCase();
|
|
168
|
+
const normalizeFilepath = (value: string) => value.replace(/\\/g, '/');
|
|
101
169
|
const tokenize = (value: string) =>
|
|
102
170
|
normalizeText(value)
|
|
103
171
|
.split(/[^a-z0-9/_.-]+/i)
|
|
104
172
|
.map((token) => token.trim())
|
|
105
173
|
.filter(Boolean);
|
|
106
174
|
|
|
175
|
+
type TNodeFs = {
|
|
176
|
+
existsSync: (filepath: string) => boolean;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
type TNodePath = {
|
|
180
|
+
dirname: (filepath: string) => string;
|
|
181
|
+
join: (...segments: string[]) => string;
|
|
182
|
+
relative: (from: string, to: string) => string;
|
|
183
|
+
resolve: (...segments: string[]) => string;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
type TConnectModule = {
|
|
187
|
+
buildConnectResponse: (typeof import('./connect'))['buildConnectResponse'];
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
let cachedNodeFs: TNodeFs | null | undefined;
|
|
191
|
+
let cachedNodePath: TNodePath | null | undefined;
|
|
192
|
+
let cachedConnectModule: TConnectModule | null | undefined;
|
|
193
|
+
|
|
194
|
+
const getNodeFs = () => {
|
|
195
|
+
if (cachedNodeFs !== undefined) return cachedNodeFs || undefined;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
cachedNodeFs = (eval('require')('fs') as TNodeFs) || null;
|
|
199
|
+
} catch (_error) {
|
|
200
|
+
cachedNodeFs = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return cachedNodeFs || undefined;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const getNodePath = () => {
|
|
207
|
+
if (cachedNodePath !== undefined) return cachedNodePath || undefined;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
cachedNodePath = (eval('require')('path') as TNodePath) || null;
|
|
211
|
+
} catch (_error) {
|
|
212
|
+
cachedNodePath = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return cachedNodePath || undefined;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const getConnectModule = () => {
|
|
219
|
+
if (cachedConnectModule !== undefined) return cachedConnectModule || undefined;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
cachedConnectModule = (eval('require')('./connect') as TConnectModule) || null;
|
|
223
|
+
} catch (_error) {
|
|
224
|
+
cachedConnectModule = null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return cachedConnectModule || undefined;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const joinPath = (...segments: string[]) => {
|
|
231
|
+
const nodePath = getNodePath();
|
|
232
|
+
if (nodePath) return nodePath.join(...segments);
|
|
233
|
+
return normalizeFilepath(segments.filter(Boolean).join('/')).replace(/\/{2,}/g, '/');
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const resolvePath = (...segments: string[]) => {
|
|
237
|
+
const nodePath = getNodePath();
|
|
238
|
+
if (nodePath) return nodePath.resolve(...segments);
|
|
239
|
+
return normalizeFilepath(segments.filter(Boolean).join('/')).replace(/\/{2,}/g, '/');
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const dirnamePath = (filepath: string) => {
|
|
243
|
+
const nodePath = getNodePath();
|
|
244
|
+
if (nodePath) return nodePath.dirname(filepath);
|
|
245
|
+
|
|
246
|
+
const normalized = normalizeFilepath(filepath).replace(/\/+$/, '');
|
|
247
|
+
const slashIndex = normalized.lastIndexOf('/');
|
|
248
|
+
if (slashIndex <= 0) return slashIndex === 0 ? '/' : '.';
|
|
249
|
+
return normalized.slice(0, slashIndex);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const relativePath = (from: string, to: string) => {
|
|
253
|
+
const nodePath = getNodePath();
|
|
254
|
+
if (nodePath) return nodePath.relative(from, to);
|
|
255
|
+
|
|
256
|
+
const normalizedFrom = normalizeFilepath(from).replace(/\/+$/, '');
|
|
257
|
+
const normalizedTo = normalizeFilepath(to);
|
|
258
|
+
if (normalizedTo.startsWith(`${normalizedFrom}/`)) return normalizedTo.slice(normalizedFrom.length + 1);
|
|
259
|
+
return normalizedTo;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const fileExists = (filepath: string) => getNodeFs()?.existsSync(filepath) === true;
|
|
263
|
+
|
|
107
264
|
/*----------------------------------
|
|
108
265
|
- HELPERS
|
|
109
266
|
----------------------------------*/
|
|
@@ -115,6 +272,60 @@ const toSource = (filepath: string, sourceLocation?: TProteumManifestSourceLocat
|
|
|
115
272
|
...(scope ? { scope } : {}),
|
|
116
273
|
});
|
|
117
274
|
|
|
275
|
+
const isGeneratedFilepath = (manifest: TProteumManifest, filepath: string) => {
|
|
276
|
+
const normalizedFilepath = normalizeFilepath(filepath);
|
|
277
|
+
const normalizedAppRoot = normalizeFilepath(manifest.app.root);
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
normalizedFilepath.includes('/.proteum/') ||
|
|
281
|
+
normalizedFilepath === `${normalizedAppRoot}/proteum.connected.json` ||
|
|
282
|
+
normalizedFilepath.endsWith('/proteum.connected.json')
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const resolveScopeLabel = ({
|
|
287
|
+
filepath,
|
|
288
|
+
manifest,
|
|
289
|
+
scope,
|
|
290
|
+
}: {
|
|
291
|
+
filepath: string;
|
|
292
|
+
manifest: TProteumManifest;
|
|
293
|
+
scope?: TProteumManifestScope;
|
|
294
|
+
}): TOwnerScopeLabel => {
|
|
295
|
+
if (scope === 'connected') return 'connected';
|
|
296
|
+
if (scope === 'framework') return 'framework';
|
|
297
|
+
if (isGeneratedFilepath(manifest, filepath)) return 'generated';
|
|
298
|
+
return 'local';
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const resolveOriginHint = ({
|
|
302
|
+
manifest,
|
|
303
|
+
filepath,
|
|
304
|
+
scopeLabel,
|
|
305
|
+
fallback,
|
|
306
|
+
connectedNamespace,
|
|
307
|
+
}: {
|
|
308
|
+
manifest: TProteumManifest;
|
|
309
|
+
filepath: string;
|
|
310
|
+
scopeLabel: TOwnerScopeLabel;
|
|
311
|
+
fallback: string;
|
|
312
|
+
connectedNamespace?: string;
|
|
313
|
+
}) => {
|
|
314
|
+
if (scopeLabel === 'connected') {
|
|
315
|
+
return connectedNamespace ? `connected boundary import from ${connectedNamespace}` : 'connected boundary import';
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (scopeLabel === 'framework') {
|
|
319
|
+
const relativeFrameworkPath = normalizeFilepath(relativePath(manifest.app.coreRoot, filepath));
|
|
320
|
+
return relativeFrameworkPath && relativeFrameworkPath !== '..'
|
|
321
|
+
? `framework-owned source ${relativeFrameworkPath}`
|
|
322
|
+
: 'framework-owned source';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (scopeLabel === 'generated') return 'generated Proteum artifact';
|
|
326
|
+
return fallback;
|
|
327
|
+
};
|
|
328
|
+
|
|
118
329
|
const pushTerm = (terms: Set<string>, value?: string) => {
|
|
119
330
|
if (!value) return;
|
|
120
331
|
|
|
@@ -128,8 +339,10 @@ const pushTerm = (terms: Set<string>, value?: string) => {
|
|
|
128
339
|
for (const token of tokenize(value)) terms.add(token);
|
|
129
340
|
};
|
|
130
341
|
|
|
131
|
-
const createRouteEntry = (route: TProteumManifestRoute): TManifestEntry => {
|
|
342
|
+
const createRouteEntry = (manifest: TProteumManifest, route: TProteumManifestRoute): TManifestEntry => {
|
|
132
343
|
const terms = new Set<string>();
|
|
344
|
+
const filepath = route.filepath;
|
|
345
|
+
const scopeLabel = resolveScopeLabel({ filepath, manifest, scope: route.scope });
|
|
133
346
|
|
|
134
347
|
pushTerm(terms, route.filepath);
|
|
135
348
|
pushTerm(terms, route.path);
|
|
@@ -145,17 +358,28 @@ const createRouteEntry = (route: TProteumManifestRoute): TManifestEntry => {
|
|
|
145
358
|
`${route.kind} ${route.methodName}`,
|
|
146
359
|
...(route.path ? [`path=${route.path}`] : []),
|
|
147
360
|
...(route.chunkId ? [`chunk=${route.chunkId}`] : []),
|
|
148
|
-
`
|
|
361
|
+
`data=${route.hasData ? 'yes' : 'no'}`,
|
|
149
362
|
],
|
|
150
363
|
kind: 'route',
|
|
151
364
|
label: route.path || route.pathRaw || route.chunkId || route.filepath,
|
|
152
365
|
searchTerms: [...terms],
|
|
153
366
|
source: toSource(route.filepath, route.sourceLocation, route.scope),
|
|
367
|
+
originHint: resolveOriginHint({
|
|
368
|
+
manifest,
|
|
369
|
+
filepath,
|
|
370
|
+
scopeLabel,
|
|
371
|
+
fallback: route.chunkId ? `local route chunk ${route.chunkId}` : 'local route source',
|
|
372
|
+
}),
|
|
373
|
+
scopeLabel,
|
|
154
374
|
};
|
|
155
375
|
};
|
|
156
376
|
|
|
157
|
-
const createControllerEntry = (controller: TProteumManifestController): TManifestEntry => {
|
|
377
|
+
const createControllerEntry = (manifest: TProteumManifest, controller: TProteumManifestController): TManifestEntry => {
|
|
158
378
|
const terms = new Set<string>();
|
|
379
|
+
const filepath = controller.filepath;
|
|
380
|
+
const scopeLabel = controller.connectedProjectNamespace
|
|
381
|
+
? 'connected'
|
|
382
|
+
: resolveScopeLabel({ filepath, manifest, scope: controller.scope });
|
|
159
383
|
|
|
160
384
|
pushTerm(terms, controller.filepath);
|
|
161
385
|
pushTerm(terms, controller.className);
|
|
@@ -163,6 +387,7 @@ const createControllerEntry = (controller: TProteumManifestController): TManifes
|
|
|
163
387
|
pushTerm(terms, controller.routePath);
|
|
164
388
|
pushTerm(terms, controller.httpPath);
|
|
165
389
|
pushTerm(terms, controller.clientAccessor);
|
|
390
|
+
pushTerm(terms, controller.connectedProjectNamespace);
|
|
166
391
|
|
|
167
392
|
return {
|
|
168
393
|
details: [
|
|
@@ -170,16 +395,27 @@ const createControllerEntry = (controller: TProteumManifestController): TManifes
|
|
|
170
395
|
`method=${controller.methodName}`,
|
|
171
396
|
`http=${controller.httpPath}`,
|
|
172
397
|
`client=${controller.clientAccessor}`,
|
|
398
|
+
...(controller.connectedProjectNamespace ? [`connected=${controller.connectedProjectNamespace}`] : []),
|
|
173
399
|
],
|
|
174
400
|
kind: 'controller',
|
|
175
401
|
label: controller.httpPath,
|
|
176
402
|
searchTerms: [...terms],
|
|
177
403
|
source: toSource(controller.filepath, controller.sourceLocation, controller.scope),
|
|
404
|
+
originHint: resolveOriginHint({
|
|
405
|
+
manifest,
|
|
406
|
+
filepath,
|
|
407
|
+
scopeLabel,
|
|
408
|
+
fallback: `local controller ${controller.className}`,
|
|
409
|
+
connectedNamespace: controller.connectedProjectNamespace,
|
|
410
|
+
}),
|
|
411
|
+
scopeLabel,
|
|
178
412
|
};
|
|
179
413
|
};
|
|
180
414
|
|
|
181
|
-
const createCommandEntry = (command: TProteumManifestCommand): TManifestEntry => {
|
|
415
|
+
const createCommandEntry = (manifest: TProteumManifest, command: TProteumManifestCommand): TManifestEntry => {
|
|
182
416
|
const terms = new Set<string>();
|
|
417
|
+
const filepath = command.filepath;
|
|
418
|
+
const scopeLabel = resolveScopeLabel({ filepath, manifest, scope: command.scope });
|
|
183
419
|
|
|
184
420
|
pushTerm(terms, command.filepath);
|
|
185
421
|
pushTerm(terms, command.className);
|
|
@@ -192,12 +428,20 @@ const createCommandEntry = (command: TProteumManifestCommand): TManifestEntry =>
|
|
|
192
428
|
label: command.path,
|
|
193
429
|
searchTerms: [...terms],
|
|
194
430
|
source: toSource(command.filepath, command.sourceLocation, command.scope),
|
|
431
|
+
originHint: resolveOriginHint({
|
|
432
|
+
manifest,
|
|
433
|
+
filepath,
|
|
434
|
+
scopeLabel,
|
|
435
|
+
fallback: `local command ${command.className}`,
|
|
436
|
+
}),
|
|
437
|
+
scopeLabel,
|
|
195
438
|
};
|
|
196
439
|
};
|
|
197
440
|
|
|
198
|
-
const createServiceEntry = (service: TProteumManifestService): TManifestEntry => {
|
|
441
|
+
const createServiceEntry = (manifest: TProteumManifest, service: TProteumManifestService): TManifestEntry => {
|
|
199
442
|
const terms = new Set<string>();
|
|
200
|
-
const sourceFilepath = service.sourceFilepath ||
|
|
443
|
+
const sourceFilepath = service.sourceFilepath || service.importPath || service.registeredName;
|
|
444
|
+
const scopeLabel = resolveScopeLabel({ filepath: sourceFilepath, manifest, scope: service.scope });
|
|
201
445
|
|
|
202
446
|
pushTerm(terms, service.registeredName);
|
|
203
447
|
pushTerm(terms, service.className);
|
|
@@ -217,12 +461,21 @@ const createServiceEntry = (service: TProteumManifestService): TManifestEntry =>
|
|
|
217
461
|
kind: 'service',
|
|
218
462
|
label: service.registeredName,
|
|
219
463
|
searchTerms: [...terms],
|
|
220
|
-
source: toSource(sourceFilepath
|
|
464
|
+
source: toSource(sourceFilepath, undefined, service.scope),
|
|
465
|
+
originHint: resolveOriginHint({
|
|
466
|
+
manifest,
|
|
467
|
+
filepath: sourceFilepath,
|
|
468
|
+
scopeLabel,
|
|
469
|
+
fallback: `service registration ${service.registeredName}`,
|
|
470
|
+
}),
|
|
471
|
+
scopeLabel,
|
|
221
472
|
};
|
|
222
473
|
};
|
|
223
474
|
|
|
224
|
-
const createLayoutEntry = (layout: TProteumManifestLayout): TManifestEntry => {
|
|
475
|
+
const createLayoutEntry = (manifest: TProteumManifest, layout: TProteumManifestLayout): TManifestEntry => {
|
|
225
476
|
const terms = new Set<string>();
|
|
477
|
+
const filepath = layout.filepath;
|
|
478
|
+
const scopeLabel = resolveScopeLabel({ filepath, manifest, scope: layout.scope });
|
|
226
479
|
|
|
227
480
|
pushTerm(terms, layout.filepath);
|
|
228
481
|
pushTerm(terms, layout.chunkId);
|
|
@@ -234,35 +487,57 @@ const createLayoutEntry = (layout: TProteumManifestLayout): TManifestEntry => {
|
|
|
234
487
|
label: layout.chunkId || layout.filepath,
|
|
235
488
|
searchTerms: [...terms],
|
|
236
489
|
source: toSource(layout.filepath, undefined, layout.scope),
|
|
490
|
+
originHint: resolveOriginHint({
|
|
491
|
+
manifest,
|
|
492
|
+
filepath,
|
|
493
|
+
scopeLabel,
|
|
494
|
+
fallback: layout.chunkId ? `layout chunk ${layout.chunkId}` : 'layout source',
|
|
495
|
+
}),
|
|
496
|
+
scopeLabel,
|
|
237
497
|
};
|
|
238
498
|
};
|
|
239
499
|
|
|
240
|
-
const createDiagnosticEntry = (diagnostic: TProteumManifestDiagnostic): TManifestEntry => {
|
|
500
|
+
const createDiagnosticEntry = (manifest: TProteumManifest, diagnostic: TProteumManifestDiagnostic): TManifestEntry => {
|
|
241
501
|
const terms = new Set<string>();
|
|
502
|
+
const filepath = diagnostic.filepath;
|
|
503
|
+
const scopeLabel = resolveScopeLabel({ filepath, manifest });
|
|
242
504
|
|
|
243
505
|
pushTerm(terms, diagnostic.code);
|
|
244
506
|
pushTerm(terms, diagnostic.message);
|
|
245
507
|
pushTerm(terms, diagnostic.filepath);
|
|
508
|
+
pushTerm(terms, diagnostic.fixHint);
|
|
246
509
|
for (const related of diagnostic.relatedFilepaths || []) pushTerm(terms, related);
|
|
247
510
|
|
|
248
511
|
return {
|
|
249
|
-
details: [
|
|
512
|
+
details: [
|
|
513
|
+
`[${diagnostic.level}]`,
|
|
514
|
+
diagnostic.message,
|
|
515
|
+
...(diagnostic.fixHint ? [`fix=${diagnostic.fixHint}`] : []),
|
|
516
|
+
...(diagnostic.relatedFilepaths || []).map((relatedFilepath) => `related=${relatedFilepath}`),
|
|
517
|
+
],
|
|
250
518
|
kind: 'diagnostic',
|
|
251
519
|
label: diagnostic.code,
|
|
252
520
|
searchTerms: [...terms],
|
|
253
521
|
source: toSource(diagnostic.filepath, diagnostic.sourceLocation),
|
|
522
|
+
originHint: resolveOriginHint({
|
|
523
|
+
manifest,
|
|
524
|
+
filepath,
|
|
525
|
+
scopeLabel,
|
|
526
|
+
fallback: 'local diagnostic source',
|
|
527
|
+
}),
|
|
528
|
+
scopeLabel,
|
|
254
529
|
};
|
|
255
530
|
};
|
|
256
531
|
|
|
257
532
|
const buildManifestEntries = (manifest: TProteumManifest) => [
|
|
258
|
-
...manifest.routes.client.map(createRouteEntry),
|
|
259
|
-
...manifest.routes.server.map(createRouteEntry),
|
|
260
|
-
...manifest.controllers.map(createControllerEntry),
|
|
261
|
-
...manifest.commands.map(createCommandEntry),
|
|
262
|
-
...manifest.services.app.map(createServiceEntry),
|
|
263
|
-
...manifest.services.routerPlugins.map(createServiceEntry),
|
|
264
|
-
...manifest.layouts.map(createLayoutEntry),
|
|
265
|
-
...manifest.diagnostics.map(createDiagnosticEntry),
|
|
533
|
+
...manifest.routes.client.map((route) => createRouteEntry(manifest, route)),
|
|
534
|
+
...manifest.routes.server.map((route) => createRouteEntry(manifest, route)),
|
|
535
|
+
...manifest.controllers.map((controller) => createControllerEntry(manifest, controller)),
|
|
536
|
+
...manifest.commands.map((command) => createCommandEntry(manifest, command)),
|
|
537
|
+
...manifest.services.app.map((service) => createServiceEntry(manifest, service)),
|
|
538
|
+
...manifest.services.routerPlugins.map((service) => createServiceEntry(manifest, service)),
|
|
539
|
+
...manifest.layouts.map((layout) => createLayoutEntry(manifest, layout)),
|
|
540
|
+
...manifest.diagnostics.map((diagnostic) => createDiagnosticEntry(manifest, diagnostic)),
|
|
266
541
|
];
|
|
267
542
|
|
|
268
543
|
const scoreOwnerMatch = (query: string, entry: TManifestEntry) => {
|
|
@@ -299,6 +574,9 @@ const scoreOwnerMatch = (query: string, entry: TManifestEntry) => {
|
|
|
299
574
|
|
|
300
575
|
if ((entry.kind === 'controller' && entry.label === query) || (entry.kind === 'route' && entry.label === query)) score += 30;
|
|
301
576
|
|
|
577
|
+
if (entry.scopeLabel === 'connected' && tokenize(query).some((token) => token === 'connected')) score += 6;
|
|
578
|
+
if (entry.scopeLabel === 'generated' && normalizeText(query).includes('.proteum')) score += 8;
|
|
579
|
+
|
|
302
580
|
return {
|
|
303
581
|
matchedOn: [...new Set(matchedOn)],
|
|
304
582
|
score,
|
|
@@ -310,6 +588,8 @@ const toOwnerMatch = (entry: TManifestEntry, score: number, matchedOn: string[])
|
|
|
310
588
|
kind: entry.kind,
|
|
311
589
|
label: entry.label,
|
|
312
590
|
matchedOn,
|
|
591
|
+
originHint: entry.originHint,
|
|
592
|
+
scopeLabel: entry.scopeLabel,
|
|
313
593
|
score,
|
|
314
594
|
source: entry.source,
|
|
315
595
|
});
|
|
@@ -355,6 +635,35 @@ const getEventQuery = (event: TTraceEvent) => {
|
|
|
355
635
|
return '';
|
|
356
636
|
};
|
|
357
637
|
|
|
638
|
+
const readEventString = (event: TTraceEvent | undefined, key: string) => {
|
|
639
|
+
if (!event) return undefined;
|
|
640
|
+
const value = event.details[key];
|
|
641
|
+
return typeof value === 'string' ? value : undefined;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const readEventNumber = (event: TTraceEvent | undefined, key: string) => {
|
|
645
|
+
if (!event) return undefined;
|
|
646
|
+
const value = event.details[key];
|
|
647
|
+
return typeof value === 'number' ? value : undefined;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const readEventSource = (event: TTraceEvent): TOwnerSource | undefined => {
|
|
651
|
+
const source = event.details.source;
|
|
652
|
+
if (typeof source !== 'object' || !source || !('entries' in source)) return undefined;
|
|
653
|
+
|
|
654
|
+
const filepath = source.entries.filepath;
|
|
655
|
+
if (typeof filepath !== 'string' || !filepath) return undefined;
|
|
656
|
+
|
|
657
|
+
const line = source.entries.line;
|
|
658
|
+
const column = source.entries.column;
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
filepath,
|
|
662
|
+
...(typeof line === 'number' && line > 0 ? { line } : {}),
|
|
663
|
+
...(typeof column === 'number' && column > 0 ? { column } : {}),
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
|
|
358
667
|
const addSuspect = (
|
|
359
668
|
suspects: Map<string, TSuspectAccumulator>,
|
|
360
669
|
owner: TExplainOwnerMatch | undefined,
|
|
@@ -382,6 +691,450 @@ const addSuspect = (
|
|
|
382
691
|
});
|
|
383
692
|
};
|
|
384
693
|
|
|
694
|
+
const findRepoRoot = (startPath: string) => {
|
|
695
|
+
let currentPath = resolvePath(startPath);
|
|
696
|
+
|
|
697
|
+
while (true) {
|
|
698
|
+
const gitDir = joinPath(currentPath, '.git');
|
|
699
|
+
if (fileExists(gitDir)) return currentPath;
|
|
700
|
+
|
|
701
|
+
const parentPath = dirnamePath(currentPath);
|
|
702
|
+
if (parentPath === currentPath) return resolvePath(startPath);
|
|
703
|
+
currentPath = parentPath;
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const resolveGuidanceFile = ({
|
|
708
|
+
appRoot,
|
|
709
|
+
fallbackFilepath,
|
|
710
|
+
relativePath,
|
|
711
|
+
}: {
|
|
712
|
+
appRoot: string;
|
|
713
|
+
fallbackFilepath: string;
|
|
714
|
+
relativePath: string;
|
|
715
|
+
}) => {
|
|
716
|
+
const localFilepath = joinPath(appRoot, relativePath);
|
|
717
|
+
if (fileExists(localFilepath)) return { filepath: localFilepath, warning: undefined as string | undefined };
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
filepath: fallbackFilepath,
|
|
721
|
+
warning: `Missing ${relativePath} in ${appRoot}; using ${fallbackFilepath}.`,
|
|
722
|
+
};
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const resolveAreaAgents = ({
|
|
726
|
+
appRoot,
|
|
727
|
+
coreRoot,
|
|
728
|
+
ownerFilepath,
|
|
729
|
+
}: {
|
|
730
|
+
appRoot: string;
|
|
731
|
+
coreRoot: string;
|
|
732
|
+
ownerFilepath?: string;
|
|
733
|
+
}) => {
|
|
734
|
+
if (!ownerFilepath) return [];
|
|
735
|
+
|
|
736
|
+
const fallbackRoot = joinPath(coreRoot, 'agents', 'project');
|
|
737
|
+
const normalizedOwner = normalizeFilepath(resolvePath(ownerFilepath));
|
|
738
|
+
const normalizedAppRoot = normalizeFilepath(resolvePath(appRoot));
|
|
739
|
+
const relativeOwner = normalizedOwner.startsWith(`${normalizedAppRoot}/`)
|
|
740
|
+
? normalizeFilepath(relativePath(appRoot, ownerFilepath))
|
|
741
|
+
: undefined;
|
|
742
|
+
|
|
743
|
+
if (!relativeOwner) return [];
|
|
744
|
+
|
|
745
|
+
const relativeDir = normalizeFilepath(dirnamePath(relativeOwner));
|
|
746
|
+
if (relativeDir === '.' || relativeDir === '') return [];
|
|
747
|
+
|
|
748
|
+
const segments = relativeDir.split('/').filter(Boolean);
|
|
749
|
+
const areaAgents: string[] = [];
|
|
750
|
+
for (let index = 0; index < segments.length; index++) {
|
|
751
|
+
const areaRelativePath = joinPath(...segments.slice(0, index + 1), 'AGENTS.md');
|
|
752
|
+
const localFilepath = joinPath(appRoot, areaRelativePath);
|
|
753
|
+
const fallbackFilepath = joinPath(fallbackRoot, areaRelativePath);
|
|
754
|
+
|
|
755
|
+
if (fileExists(localFilepath)) areaAgents.push(localFilepath);
|
|
756
|
+
else if (fileExists(fallbackFilepath)) areaAgents.push(fallbackFilepath);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return [...new Set(areaAgents)];
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const resolveGuidance = ({
|
|
763
|
+
manifest,
|
|
764
|
+
ownerFilepath,
|
|
765
|
+
}: {
|
|
766
|
+
manifest: TProteumManifest;
|
|
767
|
+
ownerFilepath?: string;
|
|
768
|
+
}) => {
|
|
769
|
+
const fallbackRoot = joinPath(manifest.app.coreRoot, 'agents', 'project');
|
|
770
|
+
const warnings: string[] = [];
|
|
771
|
+
const agents = resolveGuidanceFile({
|
|
772
|
+
appRoot: manifest.app.root,
|
|
773
|
+
fallbackFilepath: joinPath(fallbackRoot, 'AGENTS.md'),
|
|
774
|
+
relativePath: 'AGENTS.md',
|
|
775
|
+
});
|
|
776
|
+
const diagnostics = resolveGuidanceFile({
|
|
777
|
+
appRoot: manifest.app.root,
|
|
778
|
+
fallbackFilepath: joinPath(fallbackRoot, 'diagnostics.md'),
|
|
779
|
+
relativePath: 'diagnostics.md',
|
|
780
|
+
});
|
|
781
|
+
const optimizations = resolveGuidanceFile({
|
|
782
|
+
appRoot: manifest.app.root,
|
|
783
|
+
fallbackFilepath: joinPath(fallbackRoot, 'optimizations.md'),
|
|
784
|
+
relativePath: 'optimizations.md',
|
|
785
|
+
});
|
|
786
|
+
const codingStyle = resolveGuidanceFile({
|
|
787
|
+
appRoot: manifest.app.root,
|
|
788
|
+
fallbackFilepath: joinPath(fallbackRoot, 'CODING_STYLE.md'),
|
|
789
|
+
relativePath: 'CODING_STYLE.md',
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
for (const warning of [agents.warning, diagnostics.warning, optimizations.warning, codingStyle.warning]) {
|
|
793
|
+
if (warning) warnings.push(warning);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
guidance: {
|
|
798
|
+
agents: agents.filepath,
|
|
799
|
+
diagnostics: diagnostics.filepath,
|
|
800
|
+
optimizations: optimizations.filepath,
|
|
801
|
+
codingStyle: codingStyle.filepath,
|
|
802
|
+
areaAgents: resolveAreaAgents({
|
|
803
|
+
appRoot: manifest.app.root,
|
|
804
|
+
coreRoot: manifest.app.coreRoot,
|
|
805
|
+
ownerFilepath,
|
|
806
|
+
}),
|
|
807
|
+
} satisfies TOrientGuidance,
|
|
808
|
+
warnings,
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
const getConnectedNamespaceForOwner = (manifest: TProteumManifest, owner?: TExplainOwnerMatch) => {
|
|
813
|
+
if (!owner || owner.kind !== 'controller') return undefined;
|
|
814
|
+
|
|
815
|
+
const controller = manifest.controllers.find(
|
|
816
|
+
(candidate) =>
|
|
817
|
+
candidate.httpPath === owner.label &&
|
|
818
|
+
candidate.filepath === owner.source.filepath &&
|
|
819
|
+
candidate.sourceLocation.line === (owner.source.line || candidate.sourceLocation.line),
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
return controller?.connectedProjectNamespace;
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const buildConnectedSummary = ({
|
|
826
|
+
manifest,
|
|
827
|
+
owner,
|
|
828
|
+
query,
|
|
829
|
+
}: {
|
|
830
|
+
manifest: TProteumManifest;
|
|
831
|
+
owner: TExplainOwnerResponse;
|
|
832
|
+
query: string;
|
|
833
|
+
}) => {
|
|
834
|
+
const tokens = tokenize(query);
|
|
835
|
+
const topOwner = owner.matches[0];
|
|
836
|
+
const ownerNamespace = getConnectedNamespaceForOwner(manifest, topOwner);
|
|
837
|
+
const queryLikelyCrossesBoundary =
|
|
838
|
+
owner.matches.some((match) => match.scopeLabel === 'connected') ||
|
|
839
|
+
manifest.connectedProjects.some((project) =>
|
|
840
|
+
[project.namespace, project.identityIdentifier, project.identityName].some(
|
|
841
|
+
(value) => typeof value === 'string' && tokens.some((token) => normalizeText(value).includes(token)),
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
const imports = manifest.controllers
|
|
846
|
+
.filter((controller) => controller.connectedProjectNamespace)
|
|
847
|
+
.map((controller) => {
|
|
848
|
+
const namespace = controller.connectedProjectNamespace as string;
|
|
849
|
+
const score =
|
|
850
|
+
(ownerNamespace === namespace ? 100 : 0) +
|
|
851
|
+
(topOwner?.source.filepath === controller.filepath ? 40 : 0) +
|
|
852
|
+
(tokens.some((token) => normalizeText(namespace).includes(token)) ? 20 : 0) +
|
|
853
|
+
(tokens.some((token) => normalizeText(controller.clientAccessor).includes(token)) ? 18 : 0) +
|
|
854
|
+
(tokens.some((token) => normalizeText(controller.httpPath).includes(token)) ? 18 : 0);
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
namespace,
|
|
858
|
+
controller,
|
|
859
|
+
score,
|
|
860
|
+
};
|
|
861
|
+
})
|
|
862
|
+
.filter(({ score }) => score > 0 || queryLikelyCrossesBoundary)
|
|
863
|
+
.sort((left, right) => right.score - left.score || left.controller.clientAccessor.localeCompare(right.controller.clientAccessor))
|
|
864
|
+
.slice(0, queryLikelyCrossesBoundary ? 6 : 4)
|
|
865
|
+
.map(({ namespace, controller }) => ({
|
|
866
|
+
namespace,
|
|
867
|
+
clientAccessor: controller.clientAccessor,
|
|
868
|
+
httpPath: controller.httpPath,
|
|
869
|
+
filepath: controller.filepath,
|
|
870
|
+
scopeLabel: 'connected' as const,
|
|
871
|
+
originHint: `connected boundary import from ${namespace}`,
|
|
872
|
+
}));
|
|
873
|
+
|
|
874
|
+
const producerNamespaceSet = new Set<string>([ownerNamespace, ...imports.map((item) => item.namespace)].filter(Boolean) as string[]);
|
|
875
|
+
const producers = manifest.connectedProjects
|
|
876
|
+
.map((project) => {
|
|
877
|
+
const score =
|
|
878
|
+
(producerNamespaceSet.has(project.namespace) ? 100 : 0) +
|
|
879
|
+
(tokens.some((token) => normalizeText(project.namespace).includes(token)) ? 20 : 0) +
|
|
880
|
+
(tokens.some((token) => normalizeText(project.identityIdentifier || '').includes(token)) ? 18 : 0) +
|
|
881
|
+
(tokens.some((token) => normalizeText(project.identityName || '').includes(token)) ? 18 : 0);
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
project,
|
|
885
|
+
score,
|
|
886
|
+
};
|
|
887
|
+
})
|
|
888
|
+
.filter(({ score }) => score > 0 || (queryLikelyCrossesBoundary && manifest.connectedProjects.length > 0))
|
|
889
|
+
.sort((left, right) => right.score - left.score || left.project.namespace.localeCompare(right.project.namespace))
|
|
890
|
+
.slice(0, queryLikelyCrossesBoundary ? 4 : 2)
|
|
891
|
+
.map(({ project }) => ({
|
|
892
|
+
namespace: project.namespace,
|
|
893
|
+
identityIdentifier: project.identityIdentifier,
|
|
894
|
+
identityName: project.identityName,
|
|
895
|
+
sourceKind: project.sourceKind,
|
|
896
|
+
sourceValue: project.sourceValue,
|
|
897
|
+
urlInternal: project.urlInternal,
|
|
898
|
+
controllerCount: project.controllerCount,
|
|
899
|
+
cachedContractFilepath: project.cachedContractFilepath,
|
|
900
|
+
typingMode: project.typingMode,
|
|
901
|
+
}));
|
|
902
|
+
|
|
903
|
+
return {
|
|
904
|
+
imports,
|
|
905
|
+
producers,
|
|
906
|
+
} satisfies TOrientConnected;
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const quoteShellArgument = (value: string) => JSON.stringify(value);
|
|
910
|
+
|
|
911
|
+
const resolveRequestTarget = (owner: TExplainOwnerResponse, query: string) => {
|
|
912
|
+
if (query.startsWith('/')) return query;
|
|
913
|
+
|
|
914
|
+
const topOwner = owner.matches[0];
|
|
915
|
+
if (!topOwner) return undefined;
|
|
916
|
+
if ((topOwner.kind === 'route' || topOwner.kind === 'controller') && topOwner.label.startsWith('/')) return topOwner.label;
|
|
917
|
+
|
|
918
|
+
return undefined;
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const buildNextSteps = ({
|
|
922
|
+
connected,
|
|
923
|
+
owner,
|
|
924
|
+
query,
|
|
925
|
+
}: {
|
|
926
|
+
connected: TOrientConnected;
|
|
927
|
+
owner: TExplainOwnerResponse;
|
|
928
|
+
query: string;
|
|
929
|
+
}) => {
|
|
930
|
+
const requestTarget = resolveRequestTarget(owner, query);
|
|
931
|
+
const topOwner = owner.matches[0];
|
|
932
|
+
const steps: TOrientationNextStep[] = [
|
|
933
|
+
{
|
|
934
|
+
label: 'Verify Owner',
|
|
935
|
+
command: `proteum verify owner ${quoteShellArgument(query)}`,
|
|
936
|
+
reason: 'Separate owner-scoped blocking findings from unrelated global diagnostics.',
|
|
937
|
+
},
|
|
938
|
+
];
|
|
939
|
+
|
|
940
|
+
if (topOwner?.kind === 'command') {
|
|
941
|
+
steps.push({
|
|
942
|
+
label: 'Run Command',
|
|
943
|
+
command: `proteum command ${quoteShellArgument(topOwner.label)}`,
|
|
944
|
+
reason: 'Exercise the smallest trustworthy runtime surface for the matched command owner.',
|
|
945
|
+
});
|
|
946
|
+
} else if (requestTarget) {
|
|
947
|
+
steps.push({
|
|
948
|
+
label: 'Diagnose Request',
|
|
949
|
+
command: `proteum diagnose ${quoteShellArgument(requestTarget)} --hit ${quoteShellArgument(requestTarget)}`,
|
|
950
|
+
reason: 'Hit the real runtime once and capture owner lookup, trace data, and server diagnostics together.',
|
|
951
|
+
});
|
|
952
|
+
} else {
|
|
953
|
+
steps.push({
|
|
954
|
+
label: 'Explain Owner',
|
|
955
|
+
command: `proteum explain owner ${quoteShellArgument(query)}`,
|
|
956
|
+
reason: 'Inspect the exact manifest owner match before reading more source.',
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (connected.imports.length > 0 || connected.producers.length > 0) {
|
|
961
|
+
steps.push({
|
|
962
|
+
label: 'Inspect Connected',
|
|
963
|
+
command: 'proteum connect --controllers',
|
|
964
|
+
reason: 'Confirm imported controllers, contract cache state, and runtime internal URLs for the connected boundary.',
|
|
965
|
+
});
|
|
966
|
+
} else if (requestTarget) {
|
|
967
|
+
steps.push({
|
|
968
|
+
label: 'Inspect Perf',
|
|
969
|
+
command: `proteum perf request ${quoteShellArgument(requestTarget)}`,
|
|
970
|
+
reason: 'Summarize SQL, render, cache, and fetcher cost for the same request surface.',
|
|
971
|
+
});
|
|
972
|
+
} else {
|
|
973
|
+
steps.push({
|
|
974
|
+
label: 'Check Contracts',
|
|
975
|
+
command: 'proteum doctor --contracts',
|
|
976
|
+
reason: 'Confirm the framework-owned source and generated artifact contract before broader checks.',
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return steps
|
|
981
|
+
.filter((step, index, list) => list.findIndex((candidate) => candidate.command === step.command) === index)
|
|
982
|
+
.slice(0, 3);
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
const getServiceSource = (manifest: TProteumManifest, label: string): TOwnerSource | undefined => {
|
|
986
|
+
const service = [...manifest.services.app, ...manifest.services.routerPlugins].find((candidate) => candidate.registeredName === label);
|
|
987
|
+
if (!service || !service.sourceFilepath) return undefined;
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
filepath: service.sourceFilepath,
|
|
991
|
+
...(service.scope ? { scope: service.scope } : {}),
|
|
992
|
+
};
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const pushChainItem = (items: TDiagnoseChainItem[], item: TDiagnoseChainItem | undefined) => {
|
|
996
|
+
if (!item) return;
|
|
997
|
+
|
|
998
|
+
const key = `${item.kind}:${item.label}:${item.source?.filepath || ''}:${item.source?.line || 0}:${item.source?.column || 0}`;
|
|
999
|
+
if (items.some((candidate) => `${candidate.kind}:${candidate.label}:${candidate.source?.filepath || ''}:${candidate.source?.line || 0}:${candidate.source?.column || 0}` === key)) return;
|
|
1000
|
+
|
|
1001
|
+
items.push(item);
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
export const buildRequestChain = ({
|
|
1005
|
+
manifest,
|
|
1006
|
+
owner,
|
|
1007
|
+
request,
|
|
1008
|
+
}: {
|
|
1009
|
+
manifest: TProteumManifest;
|
|
1010
|
+
owner: TExplainOwnerResponse;
|
|
1011
|
+
request?: TRequestTrace;
|
|
1012
|
+
}) => {
|
|
1013
|
+
if (!request) return undefined;
|
|
1014
|
+
|
|
1015
|
+
const chain: TDiagnoseChainItem[] = [];
|
|
1016
|
+
const topOwner = owner.matches[0];
|
|
1017
|
+
|
|
1018
|
+
if (topOwner && (topOwner.kind === 'route' || topOwner.kind === 'controller')) {
|
|
1019
|
+
pushChainItem(chain, {
|
|
1020
|
+
kind: topOwner.kind,
|
|
1021
|
+
label: topOwner.label,
|
|
1022
|
+
source: topOwner.source,
|
|
1023
|
+
details: [`scope=${topOwner.scopeLabel}`, `origin=${topOwner.originHint}`],
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const routeEvent = request.events.find((event) => event.type === 'resolve.route-match');
|
|
1028
|
+
const routeLabel = readEventString(routeEvent as TTraceEvent, 'routePath');
|
|
1029
|
+
if (!topOwner || topOwner.kind !== 'route') {
|
|
1030
|
+
pushChainItem(
|
|
1031
|
+
chain,
|
|
1032
|
+
routeEvent && routeLabel
|
|
1033
|
+
? {
|
|
1034
|
+
kind: 'route',
|
|
1035
|
+
label: routeLabel,
|
|
1036
|
+
source: readEventSource(routeEvent),
|
|
1037
|
+
details: [
|
|
1038
|
+
...(readEventString(routeEvent, 'method') ? [`method=${readEventString(routeEvent, 'method')}`] : []),
|
|
1039
|
+
...(readEventString(routeEvent, 'accept') ? [`accept=${readEventString(routeEvent, 'accept')}`] : []),
|
|
1040
|
+
],
|
|
1041
|
+
}
|
|
1042
|
+
: undefined,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const controllerEvent = request.events.find((event) => event.type === 'resolve.controller-route' || event.type === 'controller.start');
|
|
1047
|
+
const controllerLabel = readEventString(controllerEvent as TTraceEvent, 'target') || request.path;
|
|
1048
|
+
if ((!topOwner || topOwner.kind !== 'controller') && request.path.startsWith('/api')) {
|
|
1049
|
+
pushChainItem(
|
|
1050
|
+
chain,
|
|
1051
|
+
controllerEvent
|
|
1052
|
+
? {
|
|
1053
|
+
kind: 'controller',
|
|
1054
|
+
label: controllerLabel,
|
|
1055
|
+
source: readEventSource(controllerEvent),
|
|
1056
|
+
details: [
|
|
1057
|
+
...(readEventString(controllerEvent, 'filepath') ? [`source=${readEventString(controllerEvent, 'filepath')}`] : []),
|
|
1058
|
+
],
|
|
1059
|
+
}
|
|
1060
|
+
: undefined,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const serviceLabels = [
|
|
1065
|
+
...request.calls.flatMap((call) => (call.serviceLabel ? [call.serviceLabel] : [])),
|
|
1066
|
+
...request.sqlQueries.flatMap((query) => (query.serviceLabel ? [query.serviceLabel] : [])),
|
|
1067
|
+
].filter(Boolean);
|
|
1068
|
+
|
|
1069
|
+
for (const serviceLabel of [...new Set(serviceLabels)].slice(0, 6)) {
|
|
1070
|
+
pushChainItem(chain, {
|
|
1071
|
+
kind: 'service',
|
|
1072
|
+
label: serviceLabel,
|
|
1073
|
+
source: getServiceSource(manifest, serviceLabel),
|
|
1074
|
+
details: ['service method observed in traced call or SQL stack'],
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const cacheHitEvent = request.events.find((event) => event.type === 'cache.hit');
|
|
1079
|
+
if (cacheHitEvent) {
|
|
1080
|
+
pushChainItem(chain, {
|
|
1081
|
+
kind: 'cache',
|
|
1082
|
+
label: `html-cache ${readEventString(cacheHitEvent, 'cachePhase') || 'hit'}`,
|
|
1083
|
+
details: [
|
|
1084
|
+
...(readEventString(cacheHitEvent, 'cacheKey') ? [`key=${readEventString(cacheHitEvent, 'cacheKey')}`] : []),
|
|
1085
|
+
],
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const cacheWriteEvents = request.events.filter((event) => event.type === 'cache.write');
|
|
1090
|
+
if (!cacheHitEvent && cacheWriteEvents.length > 0) {
|
|
1091
|
+
for (const cacheWriteEvent of cacheWriteEvents.slice(0, 3)) {
|
|
1092
|
+
pushChainItem(chain, {
|
|
1093
|
+
kind: 'cache',
|
|
1094
|
+
label: `html-cache ${readEventString(cacheWriteEvent, 'cachePhase') || 'write'}`,
|
|
1095
|
+
details: [
|
|
1096
|
+
...(readEventString(cacheWriteEvent, 'cacheKey') ? [`key=${readEventString(cacheWriteEvent, 'cacheKey')}`] : []),
|
|
1097
|
+
],
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const connectedNamespaces = [
|
|
1103
|
+
...request.calls.flatMap((call) => (call.connectedProjectNamespace ? [call.connectedProjectNamespace] : [])),
|
|
1104
|
+
...request.sqlQueries.flatMap((query) => (query.connectedNamespace ? [query.connectedNamespace] : [])),
|
|
1105
|
+
].filter(Boolean);
|
|
1106
|
+
|
|
1107
|
+
for (const namespace of [...new Set(connectedNamespaces)].slice(0, 4)) {
|
|
1108
|
+
const project = manifest.connectedProjects.find((candidate) => candidate.namespace === namespace);
|
|
1109
|
+
pushChainItem(chain, {
|
|
1110
|
+
kind: 'connected',
|
|
1111
|
+
label: namespace,
|
|
1112
|
+
details: [
|
|
1113
|
+
...(project?.identityIdentifier ? [`identifier=${project.identityIdentifier}`] : []),
|
|
1114
|
+
...(project?.urlInternal ? [`urlInternal=${project.urlInternal}`] : []),
|
|
1115
|
+
...(project?.sourceKind ? [`source=${project.sourceKind}`] : []),
|
|
1116
|
+
],
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const sqlFingerprints = request.sqlQueries
|
|
1121
|
+
.flatMap((query) => (query.fingerprint ? [query.fingerprint] : []))
|
|
1122
|
+
.filter(Boolean);
|
|
1123
|
+
for (const fingerprint of [...new Set(sqlFingerprints)].slice(0, 8)) {
|
|
1124
|
+
const matchingQuery = request.sqlQueries.find((query) => query.fingerprint === fingerprint);
|
|
1125
|
+
pushChainItem(chain, {
|
|
1126
|
+
kind: 'sql',
|
|
1127
|
+
label: fingerprint,
|
|
1128
|
+
details: [
|
|
1129
|
+
...(matchingQuery?.operation ? [`operation=${matchingQuery.operation}`] : []),
|
|
1130
|
+
...(matchingQuery?.model ? [`model=${matchingQuery.model}`] : []),
|
|
1131
|
+
],
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
return chain.length > 0 ? chain : undefined;
|
|
1136
|
+
};
|
|
1137
|
+
|
|
385
1138
|
/*----------------------------------
|
|
386
1139
|
- PUBLIC API
|
|
387
1140
|
----------------------------------*/
|
|
@@ -402,6 +1155,37 @@ export const explainOwner = (manifest: TProteumManifest, query: string): TExplai
|
|
|
402
1155
|
return { matches, normalizedQuery, query };
|
|
403
1156
|
};
|
|
404
1157
|
|
|
1158
|
+
export const buildOrientationResponse = (manifest: TProteumManifest, query: string): TOrientResponse => {
|
|
1159
|
+
const owner = explainOwner(manifest, query);
|
|
1160
|
+
const ownerFilepath = owner.matches[0]?.source.filepath;
|
|
1161
|
+
const guidanceResolution = resolveGuidance({ manifest, ownerFilepath });
|
|
1162
|
+
const connected = buildConnectedSummary({ manifest, owner, query });
|
|
1163
|
+
const nextSteps = buildNextSteps({ connected, owner, query });
|
|
1164
|
+
const connectResponse = getConnectModule()?.buildConnectResponse(manifest, { includeControllers: true });
|
|
1165
|
+
const warnings = [...guidanceResolution.warnings];
|
|
1166
|
+
|
|
1167
|
+
if (owner.matches.length === 0) warnings.push(`No manifest owner matched "${query}".`);
|
|
1168
|
+
if (connectResponse?.diagnostics.some((diagnostic) => diagnostic.level === 'error')) {
|
|
1169
|
+
warnings.push('Connected project diagnostics contain errors; inspect `proteum connect --controllers` before broader verification.');
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
return {
|
|
1173
|
+
query,
|
|
1174
|
+
normalizedQuery: owner.normalizedQuery,
|
|
1175
|
+
app: {
|
|
1176
|
+
appRoot: manifest.app.root,
|
|
1177
|
+
repoRoot: findRepoRoot(manifest.app.root),
|
|
1178
|
+
identifier: manifest.app.identity.identifier,
|
|
1179
|
+
...(typeof manifest.env?.resolved?.routerPort === 'number' ? { routerPort: manifest.env.resolved.routerPort } : {}),
|
|
1180
|
+
},
|
|
1181
|
+
guidance: guidanceResolution.guidance,
|
|
1182
|
+
owner,
|
|
1183
|
+
connected,
|
|
1184
|
+
nextSteps,
|
|
1185
|
+
warnings,
|
|
1186
|
+
};
|
|
1187
|
+
};
|
|
1188
|
+
|
|
405
1189
|
export const buildTraceAttribution = (manifest: TProteumManifest, request?: TRequestTrace): TTraceAttributionResponse | undefined => {
|
|
406
1190
|
if (!request) return undefined;
|
|
407
1191
|
|
|
@@ -412,22 +1196,22 @@ export const buildTraceAttribution = (manifest: TProteumManifest, request?: TReq
|
|
|
412
1196
|
|
|
413
1197
|
return {
|
|
414
1198
|
calls: request.calls.flatMap((call) => {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
1199
|
+
const query = call.ownerFilepath || call.path || call.label;
|
|
1200
|
+
return query
|
|
1201
|
+
? [buildTraceItemOwner(manifest, query, 'call', call.id, `${call.method || ''} ${call.path || call.label}`.trim())]
|
|
1202
|
+
: [];
|
|
1203
|
+
}),
|
|
420
1204
|
events: request.events.flatMap((event) => {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
1205
|
+
const query = getEventQuery(event);
|
|
1206
|
+
return query ? [buildTraceItemOwner(manifest, query, 'event', `${event.index}`, `${event.type}`)] : [];
|
|
1207
|
+
}),
|
|
424
1208
|
primary,
|
|
425
1209
|
sqlQueries: request.sqlQueries.flatMap((query) => {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1210
|
+
const ownerQuery = query.ownerFilepath || query.callerPath || query.callerLabel || query.query;
|
|
1211
|
+
return ownerQuery
|
|
1212
|
+
? [buildTraceItemOwner(manifest, ownerQuery, 'sql', query.id, `${query.operation} ${query.model || query.callerPath || 'query'}`)]
|
|
1213
|
+
: [];
|
|
1214
|
+
}),
|
|
431
1215
|
};
|
|
432
1216
|
};
|
|
433
1217
|
|
|
@@ -449,6 +1233,8 @@ export const buildDiagnoseResponse = ({
|
|
|
449
1233
|
const owner = explainOwner(manifest, query);
|
|
450
1234
|
const attribution = buildTraceAttribution(manifest, request);
|
|
451
1235
|
const suspects = new Map<string, TSuspectAccumulator>();
|
|
1236
|
+
const orientation = buildOrientationResponse(manifest, query);
|
|
1237
|
+
const chain = buildRequestChain({ manifest, owner, request });
|
|
452
1238
|
|
|
453
1239
|
addSuspect(suspects, owner.matches[0], 5, 'owner query');
|
|
454
1240
|
addSuspect(suspects, attribution?.primary?.owner, 8, 'primary request');
|
|
@@ -468,6 +1254,12 @@ export const buildDiagnoseResponse = ({
|
|
|
468
1254
|
doctor,
|
|
469
1255
|
explainSummary: buildExplainSummaryItems(manifest),
|
|
470
1256
|
owner,
|
|
1257
|
+
orientation: {
|
|
1258
|
+
guidance: orientation.guidance,
|
|
1259
|
+
connected: orientation.connected,
|
|
1260
|
+
nextSteps: orientation.nextSteps,
|
|
1261
|
+
},
|
|
1262
|
+
chain,
|
|
471
1263
|
query,
|
|
472
1264
|
request,
|
|
473
1265
|
serverLogs,
|