@pikku/inspector 0.12.110 → 0.12.111
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/dist/add/add-channel.js +8 -0
- package/dist/add/add-file-with-factory.d.ts +7 -1
- package/dist/add/add-file-with-factory.js +63 -2
- package/dist/add/add-functions.js +18 -1
- package/dist/add/add-pikku-studio.d.ts +15 -0
- package/dist/add/add-pikku-studio.js +307 -0
- package/dist/add/add-rpc-invocations.js +12 -0
- package/dist/add/check-disabled-references.d.ts +27 -0
- package/dist/add/check-disabled-references.js +226 -0
- package/dist/error-codes.d.ts +9 -0
- package/dist/error-codes.js +9 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/initial-state.js +1 -0
- package/dist/inspector.js +4 -0
- package/dist/lite.d.ts +3 -0
- package/dist/lite.js +2 -0
- package/dist/types.d.ts +49 -13
- package/dist/utils/addon-dependencies.d.ts +43 -0
- package/dist/utils/addon-dependencies.js +91 -0
- package/dist/utils/find-unused-functions.d.ts +20 -0
- package/dist/utils/find-unused-functions.js +132 -0
- package/dist/utils/get-property-value.js +4 -1
- package/dist/utils/load-addon-functions-meta.js +37 -43
- package/dist/utils/post-process.js +31 -7
- package/dist/utils/serialize-inspector-state.d.ts +3 -2
- package/dist/utils/serialize-inspector-state.js +8 -4
- package/dist/visit.js +6 -3
- package/package.json +3 -3
- package/dist/add/add-define-screens.d.ts +0 -8
- package/dist/add/add-define-screens.js +0 -140
package/dist/add/add-channel.js
CHANGED
|
@@ -129,6 +129,14 @@ export function addMessagesRoutes(logger, obj, state, checker) {
|
|
|
129
129
|
chanInit = resolved;
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
// ...or is the defineChannelRoutes call itself, written inline
|
|
133
|
+
if (ts.isCallExpression(chanInit) &&
|
|
134
|
+
ts.isIdentifier(chanInit.expression) &&
|
|
135
|
+
chanInit.expression.text === 'defineChannelRoutes' &&
|
|
136
|
+
chanInit.arguments[0] &&
|
|
137
|
+
ts.isObjectLiteralExpression(chanInit.arguments[0])) {
|
|
138
|
+
chanInit = chanInit.arguments[0];
|
|
139
|
+
}
|
|
132
140
|
const refContract = resolveRefContract(chanInit, 'refChannel', state.exportedContracts.addonChannel);
|
|
133
141
|
if (refContract) {
|
|
134
142
|
const refChannelKey = getObjectPropertyName(chanElem.name);
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
-
import type { PathToNameAndType, InspectorState } from '../types.js';
|
|
2
|
+
import type { PathToNameAndType, InspectorState, InspectorLogger } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A factory binding whose wrapper call sits inside a cast: `as`, `<T>` or `!`.
|
|
5
|
+
* A cast changes the type the inspector would read, and hides a mismatch the
|
|
6
|
+
* factory should fix, so it fails the build instead of being seen through.
|
|
7
|
+
*/
|
|
8
|
+
export declare const checkHiddenFactory: (logger: InspectorLogger, node: ts.Node, state: InspectorState) => void;
|
|
3
9
|
export declare const addFileWithFactory: (node: ts.Node, checker: ts.TypeChecker, methods: PathToNameAndType | undefined, expectedTypeName: string, state?: InspectorState) => void;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
+
import { relative } from 'node:path';
|
|
3
|
+
import { ErrorCode } from '../error-codes.js';
|
|
2
4
|
import { extractServicesFromFunction } from '../utils/extract-services.js';
|
|
3
5
|
// Mapping of wrapper function names to their corresponding types
|
|
4
6
|
const wrapperFunctionMap = {
|
|
@@ -10,6 +12,64 @@ const wrapperFunctionMap = {
|
|
|
10
12
|
pikkuAddonWireServices: 'CreateWireServices',
|
|
11
13
|
pikkuServerLifecycle: 'ServerLifecycle',
|
|
12
14
|
};
|
|
15
|
+
/** How a cast around an expression reads in a diagnostic, or undefined for none. */
|
|
16
|
+
const describeCast = (expression) => {
|
|
17
|
+
if (ts.isAsExpression(expression)) {
|
|
18
|
+
return { form: 'an `as` cast', remove: 'the cast' };
|
|
19
|
+
}
|
|
20
|
+
if (ts.isTypeAssertionExpression(expression)) {
|
|
21
|
+
return { form: 'a `<T>` cast', remove: 'the cast' };
|
|
22
|
+
}
|
|
23
|
+
if (ts.isNonNullExpression(expression)) {
|
|
24
|
+
return { form: 'a non-null assertion (`!`)', remove: 'the `!`' };
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
/** `satisfies` and parentheses leave the value's type as written, so a factory inside them is read as a direct call. */
|
|
29
|
+
const isTransparent = (expression) => ts.isSatisfiesExpression(expression) ||
|
|
30
|
+
ts.isParenthesizedExpression(expression);
|
|
31
|
+
const unwrapTransparent = (expression) => {
|
|
32
|
+
while (isTransparent(expression))
|
|
33
|
+
expression = expression.expression;
|
|
34
|
+
return expression;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A factory binding whose wrapper call sits inside a cast: `as`, `<T>` or `!`.
|
|
38
|
+
* A cast changes the type the inspector would read, and hides a mismatch the
|
|
39
|
+
* factory should fix, so it fails the build instead of being seen through.
|
|
40
|
+
*/
|
|
41
|
+
export const checkHiddenFactory = (logger, node, state) => {
|
|
42
|
+
if (!ts.isVariableDeclaration(node) || !node.initializer)
|
|
43
|
+
return;
|
|
44
|
+
let inner = node.initializer;
|
|
45
|
+
let cast;
|
|
46
|
+
for (;;) {
|
|
47
|
+
const found = describeCast(inner);
|
|
48
|
+
if (found) {
|
|
49
|
+
cast ??= found;
|
|
50
|
+
inner = inner
|
|
51
|
+
.expression;
|
|
52
|
+
}
|
|
53
|
+
else if (isTransparent(inner)) {
|
|
54
|
+
inner = inner.expression;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!cast ||
|
|
61
|
+
!ts.isCallExpression(inner) ||
|
|
62
|
+
!ts.isIdentifier(inner.expression) ||
|
|
63
|
+
!wrapperFunctionMap[inner.expression.text]) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const sourceFile = node.getSourceFile();
|
|
67
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
68
|
+
const wrapper = inner.expression.text;
|
|
69
|
+
logger.critical(ErrorCode.FACTORY_NOT_A_DIRECT_CALL, `'${node.name.getText()}' at ${relative(state.rootDir, sourceFile.fileName)}:${line + 1} wraps ${wrapper}(...) in ${cast.form}. ` +
|
|
70
|
+
`The inspector reads a factory only from a direct ${wrapper}(...) call, so this one would not be registered and nothing it provides would exist at runtime. ` +
|
|
71
|
+
`Remove ${cast.remove}, and fix the type mismatch it hides in the factory itself.`);
|
|
72
|
+
};
|
|
13
73
|
/**
|
|
14
74
|
* What an addon's services factory takes from the parent: the names
|
|
15
75
|
* destructured off the parameter carrying the parent's bag, either in the
|
|
@@ -126,8 +186,9 @@ export const addFileWithFactory = (node, checker, methods = new Map(), expectedT
|
|
|
126
186
|
const variableName = node.name.getText();
|
|
127
187
|
// Check for wrapper function calls FIRST (e.g., pikkuConfig(...), pikkuServices(...))
|
|
128
188
|
// This handles both cases: with and without explicit type annotations
|
|
129
|
-
|
|
130
|
-
|
|
189
|
+
const initializer = node.initializer && unwrapTransparent(node.initializer);
|
|
190
|
+
if (initializer && ts.isCallExpression(initializer)) {
|
|
191
|
+
const callExpression = initializer;
|
|
131
192
|
const expression = callExpression.expression;
|
|
132
193
|
if (ts.isIdentifier(expression)) {
|
|
133
194
|
const wrapperFunctionName = expression.text;
|
|
@@ -40,6 +40,7 @@ import { findPiiPaths } from '../utils/check-pii-output.js';
|
|
|
40
40
|
import { findClassifiedSinks } from '../utils/check-classified-sinks.js';
|
|
41
41
|
import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
|
|
42
42
|
import { resolveCalleeName } from '../utils/resolve-callee-name.js';
|
|
43
|
+
import { readFunctionDisabled, recordDisabledFunction, } from './check-disabled-references.js';
|
|
43
44
|
import { addonZodContractViolation } from '../utils/validate-addon-zod-contract.js';
|
|
44
45
|
const isValidVariableName = (name) => {
|
|
45
46
|
const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
@@ -382,7 +383,23 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
382
383
|
if (args.length === 0)
|
|
383
384
|
return;
|
|
384
385
|
let { pikkuFuncId, name, explicitName, exportedName } = extractFunctionName(node, checker, state.rootDir);
|
|
385
|
-
if (!pikkuFuncId
|
|
386
|
+
if (!pikkuFuncId)
|
|
387
|
+
return;
|
|
388
|
+
// Before the inline (`__temp_`) bail-out: a disabled function defined inline
|
|
389
|
+
// in a wiring is still used by it, and is refused rather than skipped.
|
|
390
|
+
const disabled = args[0] && ts.isObjectLiteralExpression(args[0])
|
|
391
|
+
? readFunctionDisabled(args[0], name, logger)
|
|
392
|
+
: null;
|
|
393
|
+
if (disabled) {
|
|
394
|
+
recordDisabledFunction(logger, state, node, pikkuFuncId, {
|
|
395
|
+
name,
|
|
396
|
+
sourceFile: node.getSourceFile().fileName,
|
|
397
|
+
...(exportedName ? { exportedName } : {}),
|
|
398
|
+
...disabled,
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (pikkuFuncId.startsWith('__temp_')) {
|
|
386
403
|
return;
|
|
387
404
|
}
|
|
388
405
|
// pikku's own scenario instrumentation, which older projects still carry as a
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import type { InspectorState, InspectorLogger } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Detect `pikkuStudio({ title, screens })` and record it as the package's
|
|
5
|
+
* `studio` metadata. Every field must be a literal: one that is not is
|
|
6
|
+
* reported and its screen left out, since a screen the host cannot read would
|
|
7
|
+
* otherwise vanish with no sign.
|
|
8
|
+
*/
|
|
9
|
+
export declare function addPikkuStudio(node: ts.Node, state: InspectorState, logger: InspectorLogger): void;
|
|
10
|
+
/**
|
|
11
|
+
* The checks on `pikkuStudio` that need the whole package: every title is a
|
|
12
|
+
* key in the addon's `messages/en.json` (a key missing from another locale is
|
|
13
|
+
* a warning), and every nav badge names one of the addon's functions.
|
|
14
|
+
*/
|
|
15
|
+
export declare function validatePikkuStudio(logger: InspectorLogger, state: InspectorState): void;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { ErrorCode } from '../error-codes.js';
|
|
5
|
+
const SCOPES = ['studio', 'team', 'project'];
|
|
6
|
+
const NAV_AREAS = ['rail', 'team', 'project', 'account'];
|
|
7
|
+
const propName = (prop) => {
|
|
8
|
+
if (!ts.isPropertyAssignment(prop))
|
|
9
|
+
return undefined;
|
|
10
|
+
return ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
|
|
11
|
+
? prop.name.text
|
|
12
|
+
: undefined;
|
|
13
|
+
};
|
|
14
|
+
const stringArray = (node) => {
|
|
15
|
+
if (!ts.isArrayLiteralExpression(node))
|
|
16
|
+
return undefined;
|
|
17
|
+
const values = [];
|
|
18
|
+
for (const element of node.elements) {
|
|
19
|
+
if (!ts.isStringLiteral(element))
|
|
20
|
+
return undefined;
|
|
21
|
+
values.push(element.text);
|
|
22
|
+
}
|
|
23
|
+
return values;
|
|
24
|
+
};
|
|
25
|
+
const booleanLiteral = (node) => node.kind === ts.SyntaxKind.TrueKeyword
|
|
26
|
+
? true
|
|
27
|
+
: node.kind === ts.SyntaxKind.FalseKeyword
|
|
28
|
+
? false
|
|
29
|
+
: undefined;
|
|
30
|
+
const oneOf = (node, values) => ts.isStringLiteral(node) && values.includes(node.text)
|
|
31
|
+
? node.text
|
|
32
|
+
: undefined;
|
|
33
|
+
const numberLiteral = (node) => {
|
|
34
|
+
if (ts.isNumericLiteral(node))
|
|
35
|
+
return Number(node.text);
|
|
36
|
+
if (ts.isPrefixUnaryExpression(node) &&
|
|
37
|
+
node.operator === ts.SyntaxKind.MinusToken &&
|
|
38
|
+
ts.isNumericLiteral(node.operand))
|
|
39
|
+
return -Number(node.operand.text);
|
|
40
|
+
return undefined;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The module specifier of `() => import('./screens/x')`. Anything else cannot
|
|
44
|
+
* be handed to the host's bundler as a lazy import, so it is not read.
|
|
45
|
+
*/
|
|
46
|
+
const lazyImportSpecifier = (node) => {
|
|
47
|
+
if (!ts.isArrowFunction(node))
|
|
48
|
+
return undefined;
|
|
49
|
+
const body = node.body;
|
|
50
|
+
if (!ts.isCallExpression(body) ||
|
|
51
|
+
body.expression.kind !== ts.SyntaxKind.ImportKeyword)
|
|
52
|
+
return undefined;
|
|
53
|
+
const [specifier] = body.arguments;
|
|
54
|
+
return specifier && ts.isStringLiteral(specifier) ? specifier.text : undefined;
|
|
55
|
+
};
|
|
56
|
+
const readNav = (node, fail) => {
|
|
57
|
+
if (!ts.isObjectLiteralExpression(node)) {
|
|
58
|
+
fail(`A screen's nav must be an object literal, got: ${node.getText()}`);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
let area;
|
|
62
|
+
let icon;
|
|
63
|
+
let order;
|
|
64
|
+
let badge;
|
|
65
|
+
let ok = true;
|
|
66
|
+
for (const field of node.properties) {
|
|
67
|
+
const key = propName(field);
|
|
68
|
+
if (!key || !ts.isPropertyAssignment(field)) {
|
|
69
|
+
fail(`A screen's nav field must be a literal, got: ${field.getText()}`);
|
|
70
|
+
ok = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const value = field.initializer;
|
|
74
|
+
let read;
|
|
75
|
+
if (key === 'area')
|
|
76
|
+
read = area = oneOf(value, NAV_AREAS);
|
|
77
|
+
else if (key === 'icon')
|
|
78
|
+
read = icon = ts.isStringLiteral(value) ? value.text : undefined;
|
|
79
|
+
else if (key === 'order')
|
|
80
|
+
read = order = numberLiteral(value);
|
|
81
|
+
else if (key === 'badge')
|
|
82
|
+
read = badge = ts.isStringLiteral(value) ? value.text : undefined;
|
|
83
|
+
else
|
|
84
|
+
continue;
|
|
85
|
+
if (read === undefined) {
|
|
86
|
+
fail(`A screen's nav ${key} must be ${key === 'area' ? `one of ${NAV_AREAS.map((a) => `'${a}'`).join(', ')}` : 'a literal'}, got: ${value.getText()}`);
|
|
87
|
+
ok = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!ok)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (!area || icon === undefined || order === undefined) {
|
|
93
|
+
fail(`A screen's nav needs a literal area, icon and order: ${node.getText()}`);
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
return { area, icon, order, ...(badge !== undefined ? { badge } : {}) };
|
|
97
|
+
};
|
|
98
|
+
const readScreen = (element, fail) => {
|
|
99
|
+
if (!ts.isObjectLiteralExpression(element)) {
|
|
100
|
+
fail(`A pikkuStudio screen must be an object literal, got: ${element.getText()}`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
let path;
|
|
104
|
+
let title;
|
|
105
|
+
let scope;
|
|
106
|
+
let nav;
|
|
107
|
+
let replaces;
|
|
108
|
+
let needsApp;
|
|
109
|
+
let layout;
|
|
110
|
+
let scopes;
|
|
111
|
+
let component;
|
|
112
|
+
let app;
|
|
113
|
+
let ok = true;
|
|
114
|
+
for (const field of element.properties) {
|
|
115
|
+
const key = propName(field);
|
|
116
|
+
if (!key || !ts.isPropertyAssignment(field)) {
|
|
117
|
+
fail(`A screen's field must be a literal, got: ${field.getText()}`);
|
|
118
|
+
ok = false;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const value = field.initializer;
|
|
122
|
+
let read;
|
|
123
|
+
if (key === 'path')
|
|
124
|
+
read = path = ts.isStringLiteral(value) ? value.text : undefined;
|
|
125
|
+
else if (key === 'title')
|
|
126
|
+
read = title = ts.isStringLiteral(value) ? value.text : undefined;
|
|
127
|
+
else if (key === 'scope')
|
|
128
|
+
read = scope = oneOf(value, SCOPES);
|
|
129
|
+
else if (key === 'nav') {
|
|
130
|
+
nav = readNav(value, fail);
|
|
131
|
+
if (!nav)
|
|
132
|
+
ok = false;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
else if (key === 'replaces')
|
|
136
|
+
read = replaces = booleanLiteral(value);
|
|
137
|
+
else if (key === 'needsApp')
|
|
138
|
+
read = needsApp = booleanLiteral(value);
|
|
139
|
+
else if (key === 'layout')
|
|
140
|
+
read = layout = oneOf(value, ['bare']);
|
|
141
|
+
else if (key === 'scopes') {
|
|
142
|
+
read = scopes = stringArray(value);
|
|
143
|
+
if (!scopes) {
|
|
144
|
+
fail(`A screen's scopes must be an array of string literals, got: ${value.getText()}`);
|
|
145
|
+
ok = false;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (key === 'component') {
|
|
150
|
+
read = component = lazyImportSpecifier(value);
|
|
151
|
+
if (!component) {
|
|
152
|
+
fail(`A screen's component must be () => import('<literal>'), got: ${value.getText()}`);
|
|
153
|
+
ok = false;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (key === 'app')
|
|
158
|
+
read = app = ts.isStringLiteral(value) ? value.text : undefined;
|
|
159
|
+
else
|
|
160
|
+
continue;
|
|
161
|
+
if (read === undefined) {
|
|
162
|
+
fail(`A screen's ${key} must be ${key === 'scope' ? `one of ${SCOPES.map((a) => `'${a}'`).join(', ')}` : key === 'layout' ? `'bare'` : 'a literal'}, got: ${value.getText()}`);
|
|
163
|
+
ok = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!ok)
|
|
167
|
+
return undefined;
|
|
168
|
+
if (!path || !title || !scope || (!component && !app)) {
|
|
169
|
+
fail(`A pikkuStudio screen needs a literal path, title and scope, and either a lazy component or an app: ${element.getText()}`);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
if (component && app) {
|
|
173
|
+
fail(`Screen '${path}' sets both component and app; a screen is one or the other`);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
path,
|
|
178
|
+
title,
|
|
179
|
+
scope,
|
|
180
|
+
...(nav ? { nav } : {}),
|
|
181
|
+
...(replaces !== undefined ? { replaces } : {}),
|
|
182
|
+
...(needsApp !== undefined ? { needsApp } : {}),
|
|
183
|
+
...(layout ? { layout } : {}),
|
|
184
|
+
...(scopes ? { scopes } : {}),
|
|
185
|
+
...(component ? { component } : { app: app }),
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Detect `pikkuStudio({ title, screens })` and record it as the package's
|
|
190
|
+
* `studio` metadata. Every field must be a literal: one that is not is
|
|
191
|
+
* reported and its screen left out, since a screen the host cannot read would
|
|
192
|
+
* otherwise vanish with no sign.
|
|
193
|
+
*/
|
|
194
|
+
export function addPikkuStudio(node, state, logger) {
|
|
195
|
+
if (!ts.isCallExpression(node))
|
|
196
|
+
return;
|
|
197
|
+
const { expression, arguments: args } = node;
|
|
198
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'pikkuStudio')
|
|
199
|
+
return;
|
|
200
|
+
const [firstArg] = args;
|
|
201
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
|
|
202
|
+
return;
|
|
203
|
+
const fail = (message) => logger.critical(ErrorCode.ADDON_SCREENS_NOT_STATIC, message);
|
|
204
|
+
let title;
|
|
205
|
+
const screens = [];
|
|
206
|
+
for (const prop of firstArg.properties) {
|
|
207
|
+
const key = propName(prop);
|
|
208
|
+
if (!key || !ts.isPropertyAssignment(prop)) {
|
|
209
|
+
fail(`A pikkuStudio field must be a literal, got: ${prop.getText()}`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (key === 'title') {
|
|
213
|
+
if (ts.isStringLiteral(prop.initializer)) {
|
|
214
|
+
title = prop.initializer.text;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
fail(`pikkuStudio's title must be a literal, got: ${prop.initializer.getText()}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (key === 'screens') {
|
|
221
|
+
if (!ts.isArrayLiteralExpression(prop.initializer)) {
|
|
222
|
+
fail(`pikkuStudio's screens must be an array literal, got: ${prop.initializer.getText()}`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
for (const element of prop.initializer.elements) {
|
|
226
|
+
const screen = readScreen(element, fail);
|
|
227
|
+
if (screen)
|
|
228
|
+
screens.push(screen);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!title) {
|
|
233
|
+
fail(`pikkuStudio needs a literal title`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
for (const screen of screens) {
|
|
238
|
+
if (seen.has(screen.path)) {
|
|
239
|
+
logger.critical(ErrorCode.STUDIO_SCREEN_PATH_DUPLICATE, `pikkuStudio declares two screens at '${screen.path}'; each screen needs its own path`);
|
|
240
|
+
}
|
|
241
|
+
seen.add(screen.path);
|
|
242
|
+
}
|
|
243
|
+
logger.debug(`• Found pikkuStudio: ${title} (${screens.length} screens)`);
|
|
244
|
+
state.studio = {
|
|
245
|
+
title,
|
|
246
|
+
file: node.getSourceFile().fileName,
|
|
247
|
+
screens,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** The directory of the nearest package.json above the file: the addon package root. */
|
|
251
|
+
const packageRoot = (file) => {
|
|
252
|
+
let dir = dirname(file);
|
|
253
|
+
while (true) {
|
|
254
|
+
if (existsSync(join(dir, 'package.json')))
|
|
255
|
+
return dir;
|
|
256
|
+
const parent = dirname(dir);
|
|
257
|
+
if (parent === dir)
|
|
258
|
+
return undefined;
|
|
259
|
+
dir = parent;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
const readCatalog = (file) => {
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* The checks on `pikkuStudio` that need the whole package: every title is a
|
|
272
|
+
* key in the addon's `messages/en.json` (a key missing from another locale is
|
|
273
|
+
* a warning), and every nav badge names one of the addon's functions.
|
|
274
|
+
*/
|
|
275
|
+
export function validatePikkuStudio(logger, state) {
|
|
276
|
+
const studio = state.studio;
|
|
277
|
+
if (!studio)
|
|
278
|
+
return;
|
|
279
|
+
const keys = [studio.title, ...studio.screens.map((screen) => screen.title)];
|
|
280
|
+
const root = packageRoot(studio.file);
|
|
281
|
+
const messagesDir = root ? join(root, 'messages') : undefined;
|
|
282
|
+
const en = messagesDir ? readCatalog(join(messagesDir, 'en.json')) : undefined;
|
|
283
|
+
for (const key of new Set(keys)) {
|
|
284
|
+
if (!en || !Object.hasOwn(en, key)) {
|
|
285
|
+
logger.critical(ErrorCode.STUDIO_MESSAGE_KEY_MISSING, `pikkuStudio uses the message key '${key}', which is missing from the addon's messages/en.json`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (messagesDir && existsSync(messagesDir)) {
|
|
289
|
+
const locales = readdirSync(messagesDir)
|
|
290
|
+
.filter((name) => name.endsWith('.json') && name !== 'en.json')
|
|
291
|
+
.sort();
|
|
292
|
+
for (const name of locales) {
|
|
293
|
+
const catalog = readCatalog(join(messagesDir, name)) ?? {};
|
|
294
|
+
for (const key of new Set(keys)) {
|
|
295
|
+
if (!Object.hasOwn(catalog, key)) {
|
|
296
|
+
logger.warn(`pikkuStudio uses the message key '${key}', which is missing from the addon's messages/${name}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (const screen of studio.screens) {
|
|
302
|
+
const badge = screen.nav?.badge;
|
|
303
|
+
if (badge !== undefined && !state.functions.meta[badge]) {
|
|
304
|
+
logger.critical(ErrorCode.STUDIO_BADGE_FUNCTION_NOT_FOUND, `Screen '${screen.path}' has the nav badge '${badge}', which names no function in this addon`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -164,6 +164,18 @@ export function addRPCInvocations(node, state, logger) {
|
|
|
164
164
|
state.workflows.invokedWorkflows.add(workflowName);
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
// rpc.startWorkflow('...'), workflowService.startWorkflow('...') and
|
|
168
|
+
// workflowService.runToCompletion('...') start a workflow by name
|
|
169
|
+
if (ts.isPropertyAccessExpression(expression) &&
|
|
170
|
+
(expression.name.text === 'startWorkflow' ||
|
|
171
|
+
expression.name.text === 'runToCompletion')) {
|
|
172
|
+
const [firstArg] = args;
|
|
173
|
+
if (firstArg &&
|
|
174
|
+
(ts.isStringLiteral(firstArg) ||
|
|
175
|
+
ts.isNoSubstitutionTemplateLiteral(firstArg))) {
|
|
176
|
+
state.workflows.invokedWorkflows.add(firstArg.text);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
167
179
|
const agentCall = agentInvocationName(expression);
|
|
168
180
|
if (agentCall) {
|
|
169
181
|
const [firstArg] = args;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import type { AddWiring, DisabledFunctionMeta, InspectorLogger, InspectorState } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A function's `disabled`, read from its config: `true`, or a non-empty string
|
|
5
|
+
* saying why. Anything else the build cannot read statically is refused rather
|
|
6
|
+
* than taken as enabled.
|
|
7
|
+
*/
|
|
8
|
+
export declare const readFunctionDisabled: (obj: ts.ObjectLiteralExpression, name: string, logger: InspectorLogger) => {
|
|
9
|
+
reason?: string;
|
|
10
|
+
} | null;
|
|
11
|
+
/**
|
|
12
|
+
* Records a function declared with `disabled`, in place of everything
|
|
13
|
+
* addFunctions would otherwise record. A function defined inline in what
|
|
14
|
+
* uses it is referenced by that, so it is refused instead.
|
|
15
|
+
*/
|
|
16
|
+
export declare const recordDisabledFunction: (logger: InspectorLogger, state: InspectorState, node: ts.CallExpression, pikkuFuncId: string, meta: DisabledFunctionMeta) => void;
|
|
17
|
+
/**
|
|
18
|
+
* PKU562: anything that still references a disabled function — a wiring, an
|
|
19
|
+
* agent's tools, an `rpc.invoke` by name, any other code. Runs in the routes
|
|
20
|
+
* sweep, after every function is registered, so it sees the whole set.
|
|
21
|
+
*/
|
|
22
|
+
export declare const checkDisabledReferences: AddWiring;
|
|
23
|
+
/**
|
|
24
|
+
* PKU562 for workflow steps, which name the function they call as a string
|
|
25
|
+
* the workflow meta already holds. Runs once, after the routes sweep.
|
|
26
|
+
*/
|
|
27
|
+
export declare const checkDisabledWorkflowSteps: (logger: InspectorLogger, state: InspectorState) => void;
|