@pikku/inspector 0.12.64 → 0.12.66
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/CHANGELOG.md +60 -0
- package/dist/add/add-cli.js +3 -11
- package/dist/add/add-rpc-invocations.js +25 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inspector.js +5 -0
- package/dist/types.d.ts +18 -0
- package/dist/utils/post-process.js +10 -11
- package/dist/utils/resolve-core-type.d.ts +21 -0
- package/dist/utils/resolve-core-type.js +29 -0
- package/package.json +3 -3
- package/src/add/add-cli.ts +6 -12
- package/src/add/add-rpc-invocations.test.ts +50 -0
- package/src/add/add-rpc-invocations.ts +26 -2
- package/src/index.ts +1 -0
- package/src/inspector.ts +5 -0
- package/src/types.ts +18 -0
- package/src/utils/post-process.test.ts +122 -0
- package/src/utils/post-process.ts +21 -11
- package/src/utils/resolve-core-type.test.ts +142 -0
- package/src/utils/resolve-core-type.ts +35 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
+
## 0.12.66
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 2f94a0a: Stop `pikku all` failing on a diagnostic its own codegen went on to fix.
|
|
6
|
+
|
|
7
|
+
The run inspects several times, because generating the scaffold, the leaf indexes and the type files each changes the source graph the next inspection reads. Every full inspection re-runs every validator, so the newest pass is the complete one — but the logger accumulated diagnostics across all of them, and the build gate failed the run if any pass had ever recorded a critical.
|
|
8
|
+
|
|
9
|
+
A project whose system roles grant a scaffold-declared scope (`virtualUser:*`) therefore could not build from clean: the pass taken before the scaffold existed raised PKU124, the pass taken after was clean, and the run failed anyway. Same for PKU951 on a scaffold-declared secret.
|
|
10
|
+
|
|
11
|
+
Validation diagnostics are now scoped to the pass that recorded them, so a later pass supersedes an earlier one. Diagnostics reported outside a validation pass — by a generator or a command — are untouched, and a fault every pass reports still fails the run.
|
|
12
|
+
|
|
13
|
+
- 7eeff81: Record `rpc!.invoke(...)` as an invocation
|
|
14
|
+
|
|
15
|
+
`rpc` is optional on the wire, so a caller that knows it is there writes
|
|
16
|
+
`rpc!.invoke('someFunction', …)`. The inspector matched the receiver as a bare
|
|
17
|
+
identifier, but a non-null assertion is a node of its own wrapping that
|
|
18
|
+
identifier, so those call sites were never recorded.
|
|
19
|
+
|
|
20
|
+
That is silent rather than loud. An invocation is what puts a function that is
|
|
21
|
+
not `expose: true` into the runtime registry, so the target was left
|
|
22
|
+
unregistered and the dispatch failed with `Function not found` — at run time,
|
|
23
|
+
on a call site that type-checked.
|
|
24
|
+
|
|
25
|
+
The generated virtual-user scaffold dispatches exactly this way, which meant
|
|
26
|
+
`executeVirtualUserRun` was never registered: every virtual-user run sat at
|
|
27
|
+
`running` forever with nothing logged, because the scaffold's own
|
|
28
|
+
`.catch(() => {})` swallowed the rejection.
|
|
29
|
+
|
|
30
|
+
The receiver is now unwrapped through non-null assertions and parentheses
|
|
31
|
+
before it is matched.
|
|
32
|
+
|
|
33
|
+
- Updated dependencies [88629af]
|
|
34
|
+
- Updated dependencies [f1ccfe3]
|
|
35
|
+
- @pikku/core@0.12.96
|
|
36
|
+
|
|
37
|
+
## 0.12.65
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- b5e79c1: Resolve `Config`, `SingletonServices` and `Services` by declaration rather than
|
|
42
|
+
by name.
|
|
43
|
+
|
|
44
|
+
Service extraction read `typesLookup` under the hardcoded names the scaffold
|
|
45
|
+
happens to use, but the lookup is keyed by whatever the project named its
|
|
46
|
+
interface. A project that renamed one satisfied every required-type check and
|
|
47
|
+
then resolved to no services at all, surfacing much later as PKU724 or as every
|
|
48
|
+
singleton service turning optional. These now go through the import maps, which
|
|
49
|
+
carry the real name.
|
|
50
|
+
|
|
51
|
+
`pikku workflow` carried the same lookup as its fallback when aggregation came
|
|
52
|
+
back empty, so a project with a renamed interface was told
|
|
53
|
+
`WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED` while holding a perfectly good
|
|
54
|
+
`workflowService`.
|
|
55
|
+
|
|
56
|
+
- Updated dependencies [1cc50ef]
|
|
57
|
+
- Updated dependencies [a3deea4]
|
|
58
|
+
- Updated dependencies [2a02288]
|
|
59
|
+
- @pikku/core@0.12.95
|
|
60
|
+
|
|
1
61
|
## 0.12.64
|
|
2
62
|
|
|
3
63
|
### Patch Changes
|
package/dist/add/add-cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { extractFunctionName, makeContextBasedId, } from '../utils/extract-function-name.js';
|
|
3
|
+
import { resolveCoreType } from '../utils/resolve-core-type.js';
|
|
3
4
|
import { resolveMiddleware } from '../utils/middleware.js';
|
|
4
5
|
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
|
|
5
6
|
import { extractWireNames } from '../utils/post-process.js';
|
|
@@ -538,8 +539,8 @@ function extractEnumFromPropertyType(type, propertyName, typeChecker) {
|
|
|
538
539
|
*/
|
|
539
540
|
function extractEnumFromConfigType(logger, propertyName, typeChecker, inspectorState, _inspectorOptions) {
|
|
540
541
|
// Look for Config type in typesLookup
|
|
541
|
-
const
|
|
542
|
-
if (!
|
|
542
|
+
const configType = resolveCoreType(inspectorState.typesLookup, inspectorState.configTypeImportMap);
|
|
543
|
+
if (!configType) {
|
|
543
544
|
// Only warn once per CLI file to avoid spamming logs
|
|
544
545
|
if (!configTypeWarningShown.has('missing-config-type')) {
|
|
545
546
|
configTypeWarningShown.add('missing-config-type');
|
|
@@ -548,15 +549,6 @@ function extractEnumFromConfigType(logger, propertyName, typeChecker, inspectorS
|
|
|
548
549
|
}
|
|
549
550
|
return null;
|
|
550
551
|
}
|
|
551
|
-
// Use the first Config type (there should only be one)
|
|
552
|
-
const configType = configTypes[0];
|
|
553
|
-
if (!configType) {
|
|
554
|
-
if (!configTypeWarningShown.has('undefined-config-type')) {
|
|
555
|
-
configTypeWarningShown.add('undefined-config-type');
|
|
556
|
-
logger.warn(`Config type is undefined in typesLookup.`);
|
|
557
|
-
}
|
|
558
|
-
return null;
|
|
559
|
-
}
|
|
560
552
|
// Extract enum from the property
|
|
561
553
|
return extractEnumFromPropertyType(configType, propertyName, typeChecker);
|
|
562
554
|
}
|
|
@@ -13,6 +13,25 @@ function outerParent(node) {
|
|
|
13
13
|
function findCastArg(args) {
|
|
14
14
|
return args.find(hasTypeCast);
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The `rpc` in `rpc.invoke(...)`, seen through whatever the author wrote to
|
|
18
|
+
* satisfy the type checker.
|
|
19
|
+
*
|
|
20
|
+
* `rpc` is optional on the wire, so a caller that knows it is there reaches for
|
|
21
|
+
* `rpc!.invoke(...)` — and a non-null assertion is a node of its own, not the
|
|
22
|
+
* identifier underneath it. Matching only the bare identifier meant those calls
|
|
23
|
+
* were never recorded as invocations, which is silent rather than loud: the
|
|
24
|
+
* target is not `expose: true`, so nothing else puts it in the registry, and
|
|
25
|
+
* the dispatch fails with "Function not found" at run time on a call site that
|
|
26
|
+
* type-checked. Parentheses are unwrapped for the same reason.
|
|
27
|
+
*/
|
|
28
|
+
function invocationReceiver(node) {
|
|
29
|
+
let inner = node;
|
|
30
|
+
while (ts.isNonNullExpression(inner) || ts.isParenthesizedExpression(inner)) {
|
|
31
|
+
inner = inner.expression;
|
|
32
|
+
}
|
|
33
|
+
return inner;
|
|
34
|
+
}
|
|
16
35
|
/**
|
|
17
36
|
* Helper to extract namespace from a namespaced function reference like 'ext:hello'
|
|
18
37
|
*/
|
|
@@ -62,10 +81,14 @@ export function addRPCInvocations(node, state, logger) {
|
|
|
62
81
|
}
|
|
63
82
|
}
|
|
64
83
|
// Check for rpc.invoke('...') calls
|
|
84
|
+
const invokeReceiver = ts.isPropertyAccessExpression(expression)
|
|
85
|
+
? invocationReceiver(expression.expression)
|
|
86
|
+
: undefined;
|
|
65
87
|
if (ts.isPropertyAccessExpression(expression) &&
|
|
66
88
|
expression.name.text === 'invoke' &&
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
invokeReceiver &&
|
|
90
|
+
ts.isIdentifier(invokeReceiver) &&
|
|
91
|
+
invokeReceiver.text === 'rpc') {
|
|
69
92
|
// Skip PKU940 for generated files — they may contain intentional casts
|
|
70
93
|
// (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
|
|
71
94
|
const sourceFileName = node.getSourceFile().fileName;
|
package/dist/index.d.ts
CHANGED
|
@@ -23,3 +23,4 @@ export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWork
|
|
|
23
23
|
export { getFilesAndMethods } from './utils/get-files-and-methods.js';
|
|
24
24
|
export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
|
|
25
25
|
export type { SerializedWorkflowGraph, SerializedWorkflowGraphs, } from './utils/workflow/graph/index.js';
|
|
26
|
+
export { resolveCoreType } from './utils/resolve-core-type.js';
|
package/dist/index.js
CHANGED
|
@@ -12,3 +12,4 @@ export { serializeMCPJson } from './utils/serialize-mcp-json.js';
|
|
|
12
12
|
export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/index.js';
|
|
13
13
|
export { getFilesAndMethods } from './utils/get-files-and-methods.js';
|
|
14
14
|
export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
|
|
15
|
+
export { resolveCoreType } from './utils/resolve-core-type.js';
|
package/dist/inspector.js
CHANGED
|
@@ -371,6 +371,10 @@ export const inspect = async (logger, routeFiles, options = {}) => {
|
|
|
371
371
|
state.filesAndMethodsErrors = errors;
|
|
372
372
|
logger.debug(`Get files and methods completed in ${(performance.now() - startFilesAndMethods).toFixed(2)}ms`);
|
|
373
373
|
if (!options.setupOnly) {
|
|
374
|
+
// Everything below re-derives from the state this pass just built, so it
|
|
375
|
+
// supersedes whatever an earlier pass concluded about a source graph the
|
|
376
|
+
// run has since finished generating into.
|
|
377
|
+
logger.beginValidationPass?.();
|
|
374
378
|
const startAggregate = performance.now();
|
|
375
379
|
// Apply the inspected auth handler service set before aggregation so it
|
|
376
380
|
// flows into requiredServices (the generated handler's own func is opaque).
|
|
@@ -408,6 +412,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
|
|
|
408
412
|
validateScopeReferences(logger, state);
|
|
409
413
|
validateSystemRoleScopes(logger, state);
|
|
410
414
|
validatePersonaRoles(logger, state);
|
|
415
|
+
logger.endValidationPass?.();
|
|
411
416
|
}
|
|
412
417
|
state.program = program;
|
|
413
418
|
return state;
|
package/dist/types.d.ts
CHANGED
|
@@ -300,6 +300,24 @@ export interface InspectorLogger {
|
|
|
300
300
|
/** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
|
|
301
301
|
critical: (code: ErrorCode, message: string) => void;
|
|
302
302
|
hasCriticalErrors: () => boolean;
|
|
303
|
+
/**
|
|
304
|
+
* Opens a validation pass, discarding whatever a previous one recorded.
|
|
305
|
+
*
|
|
306
|
+
* A single `pikku all` inspects several times, because generating the
|
|
307
|
+
* scaffold, the leaf indexes and the type files each changes the source graph
|
|
308
|
+
* the next inspection reads. The validators are pure functions of that graph
|
|
309
|
+
* and every full inspection re-runs all of them, so the newest pass is always
|
|
310
|
+
* the complete one and the ones before it are strictly superseded.
|
|
311
|
+
*
|
|
312
|
+
* Without this the diagnostics accumulated instead, and a run failed on a
|
|
313
|
+
* complaint about its own half-built input: a project whose roles reference
|
|
314
|
+
* scaffold-declared scopes was reported as failing even though the pass that
|
|
315
|
+
* ran once the scaffold existed was clean. Optional so a host supplying its
|
|
316
|
+
* own logger keeps working — one that omits it simply never discards.
|
|
317
|
+
*/
|
|
318
|
+
beginValidationPass?: () => void;
|
|
319
|
+
/** Closes the pass opened by `beginValidationPass`. */
|
|
320
|
+
endValidationPass?: () => void;
|
|
303
321
|
}
|
|
304
322
|
export type AddWiring = (logger: InspectorLogger, node: ts.Node, checker: ts.TypeChecker, state: InspectorState, options: InspectorOptions) => void;
|
|
305
323
|
export interface InspectorFilesAndMethods {
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { extractTypeKeys } from './type-utils.js';
|
|
5
5
|
import { ErrorCode } from '../error-codes.js';
|
|
6
6
|
import { findSecretAliasServices } from './secret-alias-services.js';
|
|
7
|
+
import { resolveCoreType } from './resolve-core-type.js';
|
|
7
8
|
import { relative } from 'node:path';
|
|
8
9
|
import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
|
|
9
10
|
import { flattenScopeDefinitions } from '@pikku/core/scope';
|
|
@@ -79,18 +80,16 @@ function extractAllServices(state) {
|
|
|
79
80
|
return;
|
|
80
81
|
}
|
|
81
82
|
// Extract all singleton services from the SingletonServices type
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
state.serviceAggregation.allSingletonServices = singletonServiceNames.sort();
|
|
83
|
+
const singletonServicesType = resolveCoreType(state.typesLookup, state.singletonServicesTypeImportMap);
|
|
84
|
+
if (singletonServicesType) {
|
|
85
|
+
state.serviceAggregation.allSingletonServices = extractTypeKeys(singletonServicesType).sort();
|
|
86
86
|
}
|
|
87
87
|
// Extract all services from the Services type
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
const allServiceNames = extractTypeKeys(servicesTypes[0]);
|
|
88
|
+
const servicesType = resolveCoreType(state.typesLookup, state.wireServicesTypeImportMap);
|
|
89
|
+
if (servicesType) {
|
|
91
90
|
// Wire services are those in Services but not in SingletonServices
|
|
92
91
|
const singletonSet = new Set(state.serviceAggregation.allSingletonServices);
|
|
93
|
-
state.serviceAggregation.allWireServices =
|
|
92
|
+
state.serviceAggregation.allWireServices = extractTypeKeys(servicesType)
|
|
94
93
|
.filter((name) => !singletonSet.has(name))
|
|
95
94
|
.sort();
|
|
96
95
|
}
|
|
@@ -898,11 +897,11 @@ export function validateNoSecretAliasServices(logger, checker, state) {
|
|
|
898
897
|
if (!('typesLookup' in state)) {
|
|
899
898
|
return;
|
|
900
899
|
}
|
|
901
|
-
const
|
|
902
|
-
if (!
|
|
900
|
+
const singletonServicesType = resolveCoreType(state.typesLookup, state.singletonServicesTypeImportMap);
|
|
901
|
+
if (!singletonServicesType) {
|
|
903
902
|
return;
|
|
904
903
|
}
|
|
905
|
-
const aliases = findSecretAliasServices(
|
|
904
|
+
const aliases = findSecretAliasServices(singletonServicesType, checker);
|
|
906
905
|
if (aliases.length === 0) {
|
|
907
906
|
return;
|
|
908
907
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type ts from 'typescript';
|
|
2
|
+
import type { PathToNameAndType } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the one interface extending a core type (`CoreConfig`,
|
|
5
|
+
* `CoreSingletonServices`, `CoreServices`) to the `ts.Type` the AST walk
|
|
6
|
+
* recorded for it.
|
|
7
|
+
*
|
|
8
|
+
* `typesLookup` is keyed by whatever the project named the interface, so
|
|
9
|
+
* reading it under a hardcoded `'SingletonServices'` only works for projects
|
|
10
|
+
* that follow the scaffold's naming. A project that renamed the interface still
|
|
11
|
+
* satisfies every required-type check and then resolves to no services at all,
|
|
12
|
+
* which surfaces far downstream as PKU724 or as every service turning optional.
|
|
13
|
+
* The import map carries the real name, so no name matching is needed.
|
|
14
|
+
*
|
|
15
|
+
* A project is only ever meant to declare one of each: `getMetaTypes` records
|
|
16
|
+
* `More than one ... found` when it sees a second, which `checkRequiredTypes`
|
|
17
|
+
* then reports. That check is the caller's to run, and extraction happens
|
|
18
|
+
* first, so where two declarations do exist this takes the first in traversal
|
|
19
|
+
* order rather than diagnosing the ambiguity itself.
|
|
20
|
+
*/
|
|
21
|
+
export declare const resolveCoreType: (typesLookup: Map<string, ts.Type[]>, importMap: PathToNameAndType) => ts.Type | undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the one interface extending a core type (`CoreConfig`,
|
|
3
|
+
* `CoreSingletonServices`, `CoreServices`) to the `ts.Type` the AST walk
|
|
4
|
+
* recorded for it.
|
|
5
|
+
*
|
|
6
|
+
* `typesLookup` is keyed by whatever the project named the interface, so
|
|
7
|
+
* reading it under a hardcoded `'SingletonServices'` only works for projects
|
|
8
|
+
* that follow the scaffold's naming. A project that renamed the interface still
|
|
9
|
+
* satisfies every required-type check and then resolves to no services at all,
|
|
10
|
+
* which surfaces far downstream as PKU724 or as every service turning optional.
|
|
11
|
+
* The import map carries the real name, so no name matching is needed.
|
|
12
|
+
*
|
|
13
|
+
* A project is only ever meant to declare one of each: `getMetaTypes` records
|
|
14
|
+
* `More than one ... found` when it sees a second, which `checkRequiredTypes`
|
|
15
|
+
* then reports. That check is the caller's to run, and extraction happens
|
|
16
|
+
* first, so where two declarations do exist this takes the first in traversal
|
|
17
|
+
* order rather than diagnosing the ambiguity itself.
|
|
18
|
+
*/
|
|
19
|
+
export const resolveCoreType = (typesLookup, importMap) => {
|
|
20
|
+
for (const entries of importMap.values()) {
|
|
21
|
+
for (const { type } of entries) {
|
|
22
|
+
const resolved = type ? typesLookup.get(type)?.[0] : undefined;
|
|
23
|
+
if (resolved) {
|
|
24
|
+
return resolved;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.66",
|
|
4
4
|
"description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"zod-to-ts": "^2.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@pikku/core": "^0.12.
|
|
48
|
+
"@pikku/core": "^0.12.96"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.96",
|
|
52
52
|
"@types/node": "^24.13.3"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
package/src/add/add-cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
extractFunctionName,
|
|
12
12
|
makeContextBasedId,
|
|
13
13
|
} from '../utils/extract-function-name.js'
|
|
14
|
+
import { resolveCoreType } from '../utils/resolve-core-type.js'
|
|
14
15
|
import { resolveMiddleware } from '../utils/middleware.js'
|
|
15
16
|
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js'
|
|
16
17
|
import { extractWireNames } from '../utils/post-process.js'
|
|
@@ -825,8 +826,11 @@ function extractEnumFromConfigType(
|
|
|
825
826
|
_inspectorOptions: InspectorOptions
|
|
826
827
|
): string[] | null {
|
|
827
828
|
// Look for Config type in typesLookup
|
|
828
|
-
const
|
|
829
|
-
|
|
829
|
+
const configType = resolveCoreType(
|
|
830
|
+
inspectorState.typesLookup,
|
|
831
|
+
inspectorState.configTypeImportMap
|
|
832
|
+
)
|
|
833
|
+
if (!configType) {
|
|
830
834
|
// Only warn once per CLI file to avoid spamming logs
|
|
831
835
|
if (!configTypeWarningShown.has('missing-config-type')) {
|
|
832
836
|
configTypeWarningShown.add('missing-config-type')
|
|
@@ -838,16 +842,6 @@ function extractEnumFromConfigType(
|
|
|
838
842
|
return null
|
|
839
843
|
}
|
|
840
844
|
|
|
841
|
-
// Use the first Config type (there should only be one)
|
|
842
|
-
const configType = configTypes[0]
|
|
843
|
-
if (!configType) {
|
|
844
|
-
if (!configTypeWarningShown.has('undefined-config-type')) {
|
|
845
|
-
configTypeWarningShown.add('undefined-config-type')
|
|
846
|
-
logger.warn(`Config type is undefined in typesLookup.`)
|
|
847
|
-
}
|
|
848
|
-
return null
|
|
849
|
-
}
|
|
850
|
-
|
|
851
845
|
// Extract enum from the property
|
|
852
846
|
return extractEnumFromPropertyType(configType, propertyName, typeChecker)
|
|
853
847
|
}
|
|
@@ -110,4 +110,54 @@ export const routes = { stream: { func: ref('console:streamWorkflowRun') } }
|
|
|
110
110
|
await rm(dir, { recursive: true, force: true })
|
|
111
111
|
}
|
|
112
112
|
})
|
|
113
|
+
|
|
114
|
+
test('a non-null assertion on the receiver is still an invocation', async () => {
|
|
115
|
+
const { state, dir } = await inspectFiles({
|
|
116
|
+
'caller.ts': `
|
|
117
|
+
declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
|
|
118
|
+
export async function doWork() {
|
|
119
|
+
return rpc!.invoke('executeVirtualUserRun')
|
|
120
|
+
}
|
|
121
|
+
`,
|
|
122
|
+
})
|
|
123
|
+
try {
|
|
124
|
+
assert.ok(state.rpc.invokedFunctions.has('executeVirtualUserRun'))
|
|
125
|
+
const invoked = [...state.rpc.invokedFunctionsByFile.values()][0]!
|
|
126
|
+
assert.deepStrictEqual([...invoked], ['executeVirtualUserRun'])
|
|
127
|
+
} finally {
|
|
128
|
+
await rm(dir, { recursive: true, force: true })
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a parenthesised receiver is still an invocation', async () => {
|
|
133
|
+
const { state, dir } = await inspectFiles({
|
|
134
|
+
'caller.ts': `
|
|
135
|
+
declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
|
|
136
|
+
export async function doWork() {
|
|
137
|
+
return (rpc!).invoke('listTasks')
|
|
138
|
+
}
|
|
139
|
+
`,
|
|
140
|
+
})
|
|
141
|
+
try {
|
|
142
|
+
assert.ok(state.rpc.invokedFunctions.has('listTasks'))
|
|
143
|
+
} finally {
|
|
144
|
+
await rm(dir, { recursive: true, force: true })
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('invoke on some other receiver is left alone', async () => {
|
|
149
|
+
const { state, dir } = await inspectFiles({
|
|
150
|
+
'caller.ts': `
|
|
151
|
+
declare const client: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
|
|
152
|
+
export async function doWork() {
|
|
153
|
+
return client!.invoke('notAnRpc')
|
|
154
|
+
}
|
|
155
|
+
`,
|
|
156
|
+
})
|
|
157
|
+
try {
|
|
158
|
+
assert.ok(!state.rpc.invokedFunctions.has('notAnRpc'))
|
|
159
|
+
} finally {
|
|
160
|
+
await rm(dir, { recursive: true, force: true })
|
|
161
|
+
}
|
|
162
|
+
})
|
|
113
163
|
})
|
|
@@ -20,6 +20,26 @@ function findCastArg(
|
|
|
20
20
|
return args.find(hasTypeCast)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The `rpc` in `rpc.invoke(...)`, seen through whatever the author wrote to
|
|
25
|
+
* satisfy the type checker.
|
|
26
|
+
*
|
|
27
|
+
* `rpc` is optional on the wire, so a caller that knows it is there reaches for
|
|
28
|
+
* `rpc!.invoke(...)` — and a non-null assertion is a node of its own, not the
|
|
29
|
+
* identifier underneath it. Matching only the bare identifier meant those calls
|
|
30
|
+
* were never recorded as invocations, which is silent rather than loud: the
|
|
31
|
+
* target is not `expose: true`, so nothing else puts it in the registry, and
|
|
32
|
+
* the dispatch fails with "Function not found" at run time on a call site that
|
|
33
|
+
* type-checked. Parentheses are unwrapped for the same reason.
|
|
34
|
+
*/
|
|
35
|
+
function invocationReceiver(node: ts.Expression): ts.Expression {
|
|
36
|
+
let inner = node
|
|
37
|
+
while (ts.isNonNullExpression(inner) || ts.isParenthesizedExpression(inner)) {
|
|
38
|
+
inner = inner.expression
|
|
39
|
+
}
|
|
40
|
+
return inner
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
/**
|
|
24
44
|
* Helper to extract namespace from a namespaced function reference like 'ext:hello'
|
|
25
45
|
*/
|
|
@@ -80,11 +100,15 @@ export function addRPCInvocations(
|
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
// Check for rpc.invoke('...') calls
|
|
103
|
+
const invokeReceiver = ts.isPropertyAccessExpression(expression)
|
|
104
|
+
? invocationReceiver(expression.expression)
|
|
105
|
+
: undefined
|
|
83
106
|
if (
|
|
84
107
|
ts.isPropertyAccessExpression(expression) &&
|
|
85
108
|
expression.name.text === 'invoke' &&
|
|
86
|
-
|
|
87
|
-
|
|
109
|
+
invokeReceiver &&
|
|
110
|
+
ts.isIdentifier(invokeReceiver) &&
|
|
111
|
+
invokeReceiver.text === 'rpc'
|
|
88
112
|
) {
|
|
89
113
|
// Skip PKU940 for generated files — they may contain intentional casts
|
|
90
114
|
// (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
|
package/src/index.ts
CHANGED
package/src/inspector.ts
CHANGED
|
@@ -493,6 +493,10 @@ export const inspect = async (
|
|
|
493
493
|
)
|
|
494
494
|
|
|
495
495
|
if (!options.setupOnly) {
|
|
496
|
+
// Everything below re-derives from the state this pass just built, so it
|
|
497
|
+
// supersedes whatever an earlier pass concluded about a source graph the
|
|
498
|
+
// run has since finished generating into.
|
|
499
|
+
logger.beginValidationPass?.()
|
|
496
500
|
const startAggregate = performance.now()
|
|
497
501
|
// Apply the inspected auth handler service set before aggregation so it
|
|
498
502
|
// flows into requiredServices (the generated handler's own func is opaque).
|
|
@@ -542,6 +546,7 @@ export const inspect = async (
|
|
|
542
546
|
validateScopeReferences(logger, state)
|
|
543
547
|
validateSystemRoleScopes(logger, state)
|
|
544
548
|
validatePersonaRoles(logger, state)
|
|
549
|
+
logger.endValidationPass?.()
|
|
545
550
|
}
|
|
546
551
|
|
|
547
552
|
state.program = program
|
package/src/types.ts
CHANGED
|
@@ -366,6 +366,24 @@ export interface InspectorLogger {
|
|
|
366
366
|
/** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
|
|
367
367
|
critical: (code: ErrorCode, message: string) => void
|
|
368
368
|
hasCriticalErrors: () => boolean
|
|
369
|
+
/**
|
|
370
|
+
* Opens a validation pass, discarding whatever a previous one recorded.
|
|
371
|
+
*
|
|
372
|
+
* A single `pikku all` inspects several times, because generating the
|
|
373
|
+
* scaffold, the leaf indexes and the type files each changes the source graph
|
|
374
|
+
* the next inspection reads. The validators are pure functions of that graph
|
|
375
|
+
* and every full inspection re-runs all of them, so the newest pass is always
|
|
376
|
+
* the complete one and the ones before it are strictly superseded.
|
|
377
|
+
*
|
|
378
|
+
* Without this the diagnostics accumulated instead, and a run failed on a
|
|
379
|
+
* complaint about its own half-built input: a project whose roles reference
|
|
380
|
+
* scaffold-declared scopes was reported as failing even though the pass that
|
|
381
|
+
* ran once the scaffold existed was clean. Optional so a host supplying its
|
|
382
|
+
* own logger keeps working — one that omits it simply never discards.
|
|
383
|
+
*/
|
|
384
|
+
beginValidationPass?: () => void
|
|
385
|
+
/** Closes the pass opened by `beginValidationPass`. */
|
|
386
|
+
endValidationPass?: () => void
|
|
369
387
|
}
|
|
370
388
|
|
|
371
389
|
export type AddWiring = (
|
|
@@ -12,9 +12,11 @@ import {
|
|
|
12
12
|
validateRemoteAddonAuth,
|
|
13
13
|
validateAgentToolReferences,
|
|
14
14
|
validateAgentModels,
|
|
15
|
+
validateSchemaReferences,
|
|
15
16
|
} from './post-process.js'
|
|
16
17
|
import { ErrorCode } from '../error-codes.js'
|
|
17
18
|
import type { InspectorState, InspectorLogger } from '../types.js'
|
|
19
|
+
import { TypesMap } from '../types-map.js'
|
|
18
20
|
|
|
19
21
|
const makeCriticalLogger = () => {
|
|
20
22
|
const criticals: { code: string; message: string }[] = []
|
|
@@ -928,3 +930,123 @@ describe('validateAgentModels (aliases resolve against pikku.config.json)', () =
|
|
|
928
930
|
})
|
|
929
931
|
}
|
|
930
932
|
})
|
|
933
|
+
|
|
934
|
+
const makeDiagnosticLogger = () => {
|
|
935
|
+
const diagnostics: { code: string; severity: string; message: string }[] = []
|
|
936
|
+
const logger = {
|
|
937
|
+
debug: () => {},
|
|
938
|
+
info: () => {},
|
|
939
|
+
warn: () => {},
|
|
940
|
+
error: () => {},
|
|
941
|
+
critical: () => {},
|
|
942
|
+
diagnostic: (d: any) =>
|
|
943
|
+
diagnostics.push({
|
|
944
|
+
code: String(d.code),
|
|
945
|
+
severity: d.severity,
|
|
946
|
+
message: d.message,
|
|
947
|
+
}),
|
|
948
|
+
} as unknown as InspectorLogger
|
|
949
|
+
return { logger, diagnostics }
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const makeSchemaRefState = (
|
|
953
|
+
meta: Record<string, { inputs?: string[]; outputs?: string[] }>,
|
|
954
|
+
schemas: string[],
|
|
955
|
+
types: Record<string, string> = {}
|
|
956
|
+
): InspectorState => {
|
|
957
|
+
const typesMap = new TypesMap()
|
|
958
|
+
for (const [name, path] of Object.entries(types)) {
|
|
959
|
+
typesMap.addType(name, path)
|
|
960
|
+
}
|
|
961
|
+
return {
|
|
962
|
+
functions: { meta, typesMap },
|
|
963
|
+
schemas: Object.fromEntries(schemas.map((name) => [name, {}])),
|
|
964
|
+
} as unknown as InspectorState
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
describe('validateSchemaReferences', () => {
|
|
968
|
+
test('an output type that was never exported is reported, not silent', () => {
|
|
969
|
+
const { logger, diagnostics } = makeDiagnosticLogger()
|
|
970
|
+
validateSchemaReferences(
|
|
971
|
+
logger,
|
|
972
|
+
makeSchemaRefState(
|
|
973
|
+
{ getStructure: { outputs: ['StructureSummary'] } },
|
|
974
|
+
[]
|
|
975
|
+
)
|
|
976
|
+
)
|
|
977
|
+
assert.equal(diagnostics.length, 1)
|
|
978
|
+
assert.equal(diagnostics[0]!.code, ErrorCode.SCHEMA_REFERENCE_UNRESOLVED)
|
|
979
|
+
assert.equal(diagnostics[0]!.severity, 'error')
|
|
980
|
+
assert.match(
|
|
981
|
+
diagnostics[0]!.message,
|
|
982
|
+
/getStructure\.output → 'StructureSummary'/
|
|
983
|
+
)
|
|
984
|
+
assert.match(diagnostics[0]!.message, /declared but not exported/)
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
test('an unresolved input is reported the same way', () => {
|
|
988
|
+
const { logger, diagnostics } = makeDiagnosticLogger()
|
|
989
|
+
validateSchemaReferences(
|
|
990
|
+
logger,
|
|
991
|
+
makeSchemaRefState({ getStructure: { inputs: ['StructureQuery'] } }, [])
|
|
992
|
+
)
|
|
993
|
+
assert.equal(diagnostics.length, 1)
|
|
994
|
+
assert.match(
|
|
995
|
+
diagnostics[0]!.message,
|
|
996
|
+
/getStructure\.input → 'StructureQuery'/
|
|
997
|
+
)
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
test('a generated schema resolves and says nothing', () => {
|
|
1001
|
+
const { logger, diagnostics } = makeDiagnosticLogger()
|
|
1002
|
+
validateSchemaReferences(
|
|
1003
|
+
logger,
|
|
1004
|
+
makeSchemaRefState({ getStructure: { outputs: ['StructureSummary'] } }, [
|
|
1005
|
+
'StructureSummary',
|
|
1006
|
+
])
|
|
1007
|
+
)
|
|
1008
|
+
assert.deepEqual(diagnostics, [])
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
test('primitive contracts are never schema references', () => {
|
|
1012
|
+
const { logger, diagnostics } = makeDiagnosticLogger()
|
|
1013
|
+
validateSchemaReferences(
|
|
1014
|
+
logger,
|
|
1015
|
+
makeSchemaRefState(
|
|
1016
|
+
{ getStructure: { inputs: ['string'], outputs: ['void'] } },
|
|
1017
|
+
[]
|
|
1018
|
+
)
|
|
1019
|
+
)
|
|
1020
|
+
assert.deepEqual(diagnostics, [])
|
|
1021
|
+
})
|
|
1022
|
+
|
|
1023
|
+
test('a name shared by two files is reported under the disambiguated name', () => {
|
|
1024
|
+
const { logger, diagnostics } = makeDiagnosticLogger()
|
|
1025
|
+
const typesMap = new TypesMap()
|
|
1026
|
+
const mine = typesMap.addUniqueType('StructureSummary', '/structure.ts')
|
|
1027
|
+
typesMap.addUniqueType('StructureSummary', '/other-structure.ts')
|
|
1028
|
+
const state = {
|
|
1029
|
+
functions: { meta: { getStructure: { outputs: [mine] } }, typesMap },
|
|
1030
|
+
schemas: {},
|
|
1031
|
+
} as unknown as InspectorState
|
|
1032
|
+
validateSchemaReferences(logger, state)
|
|
1033
|
+
assert.equal(diagnostics.length, 1)
|
|
1034
|
+
assert.match(diagnostics[0]!.message, new RegExp(`→ '${mine}'`))
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
test('every unresolved contract is named in one diagnostic, not one each', () => {
|
|
1038
|
+
const { logger, diagnostics } = makeDiagnosticLogger()
|
|
1039
|
+
validateSchemaReferences(
|
|
1040
|
+
logger,
|
|
1041
|
+
makeSchemaRefState(
|
|
1042
|
+
{
|
|
1043
|
+
getStructure: { outputs: ['StructureSummary'] },
|
|
1044
|
+
listStructures: { outputs: ['StructureSummary'] },
|
|
1045
|
+
},
|
|
1046
|
+
[]
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
assert.equal(diagnostics.length, 1)
|
|
1050
|
+
assert.match(diagnostics[0]!.message, /2 function contracts name a schema/)
|
|
1051
|
+
})
|
|
1052
|
+
})
|