@pikku/inspector 0.12.110 → 0.12.112
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-define-contributors.d.ts +10 -0
- package/dist/add/add-define-contributors.js +98 -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/add-wire-addon.js +11 -2
- 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 +14 -0
- package/dist/error-codes.js +14 -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 +78 -28
- 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.d.ts +1 -8
- package/dist/utils/load-addon-functions-meta.js +87 -76
- package/dist/utils/post-process.js +31 -7
- package/dist/utils/serialize-inspector-state.d.ts +6 -3
- package/dist/utils/serialize-inspector-state.js +12 -6
- package/dist/visit.js +8 -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);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import type { InspectorLogger, InspectorState } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Detect `defineContributors({ [name]: { dev?, deploy? } })` and record, per
|
|
5
|
+
* name, the module and export of each variant. A name defined twice, or a
|
|
6
|
+
* variant a host could not import by itself, is a critical error: a contributor
|
|
7
|
+
* that silently drops out would leave the host without the service it opted in
|
|
8
|
+
* to.
|
|
9
|
+
*/
|
|
10
|
+
export declare function addDefineContributors(logger: InspectorLogger, node: ts.Node, checker: ts.TypeChecker, state: InspectorState): void;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { ErrorCode } from '../error-codes.js';
|
|
3
|
+
import { callsImportedDefiner, exportedName } from './add-analytics.js';
|
|
4
|
+
const DEFINE_CONTRIBUTORS = 'defineContributors';
|
|
5
|
+
const VARIANTS = ['dev', 'deploy'];
|
|
6
|
+
const propertyKey = (name) => ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Where an exported contributor const lives and what it is exported as. A
|
|
9
|
+
* host imports exactly this, so a variant that is inline, not exported, or
|
|
10
|
+
* built by a call has no answer and is reported instead.
|
|
11
|
+
*/
|
|
12
|
+
const resolveVariant = (value, checker) => {
|
|
13
|
+
if (!ts.isIdentifier(value)) {
|
|
14
|
+
return `must be a reference to an exported const so a host can import just that variant, got: ${value.getText()}`;
|
|
15
|
+
}
|
|
16
|
+
let symbol = checker.getSymbolAtLocation(value);
|
|
17
|
+
if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
|
|
18
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
19
|
+
}
|
|
20
|
+
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
|
|
21
|
+
if (!declaration ||
|
|
22
|
+
!ts.isVariableDeclaration(declaration) ||
|
|
23
|
+
!ts.isIdentifier(declaration.name)) {
|
|
24
|
+
return `'${value.text}' does not resolve to a const declaration`;
|
|
25
|
+
}
|
|
26
|
+
const exported = exportedName(declaration, declaration.name, checker);
|
|
27
|
+
if (!exported) {
|
|
28
|
+
return `'${value.text}' is not exported by ${declaration.getSourceFile().fileName}`;
|
|
29
|
+
}
|
|
30
|
+
return { module: declaration.getSourceFile().fileName, export: exported };
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Detect `defineContributors({ [name]: { dev?, deploy? } })` and record, per
|
|
34
|
+
* name, the module and export of each variant. A name defined twice, or a
|
|
35
|
+
* variant a host could not import by itself, is a critical error: a contributor
|
|
36
|
+
* that silently drops out would leave the host without the service it opted in
|
|
37
|
+
* to.
|
|
38
|
+
*/
|
|
39
|
+
export function addDefineContributors(logger, node, checker, state) {
|
|
40
|
+
if (!ts.isCallExpression(node))
|
|
41
|
+
return;
|
|
42
|
+
if (!callsImportedDefiner(node.expression, checker, DEFINE_CONTRIBUTORS))
|
|
43
|
+
return;
|
|
44
|
+
const fail = (code, message) => logger.critical(code, message);
|
|
45
|
+
const notStatic = (message) => fail(ErrorCode.CONTRIBUTORS_NOT_STATIC, message);
|
|
46
|
+
const [argument] = node.arguments;
|
|
47
|
+
if (!argument || !ts.isObjectLiteralExpression(argument)) {
|
|
48
|
+
notStatic(`defineContributors must be called with an object literal keyed by contributor name${argument ? `, got: ${argument.getText()}` : ''}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const property of argument.properties) {
|
|
52
|
+
const name = ts.isPropertyAssignment(property)
|
|
53
|
+
? propertyKey(property.name)
|
|
54
|
+
: undefined;
|
|
55
|
+
if (!name || !ts.isPropertyAssignment(property)) {
|
|
56
|
+
notStatic(`defineContributors takes { name: { dev?, deploy? } } entries, got: ${property.getText()}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!ts.isObjectLiteralExpression(property.initializer)) {
|
|
60
|
+
notStatic(`Contributor '${name}' must be an object literal with dev and/or deploy, got: ${property.initializer.getText()}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const variants = {};
|
|
64
|
+
let valid = true;
|
|
65
|
+
for (const variant of property.initializer.properties) {
|
|
66
|
+
const key = ts.isPropertyAssignment(variant)
|
|
67
|
+
? propertyKey(variant.name)
|
|
68
|
+
: undefined;
|
|
69
|
+
if (!key ||
|
|
70
|
+
!ts.isPropertyAssignment(variant) ||
|
|
71
|
+
!VARIANTS.includes(key)) {
|
|
72
|
+
notStatic(`Contributor '${name}' takes only dev and deploy, got: ${variant.getText()}`);
|
|
73
|
+
valid = false;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const ref = resolveVariant(variant.initializer, checker);
|
|
77
|
+
if (typeof ref === 'string') {
|
|
78
|
+
notStatic(`Contributor '${name}' ${key} ${ref}`);
|
|
79
|
+
valid = false;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
variants[key] = ref;
|
|
83
|
+
}
|
|
84
|
+
if (!valid)
|
|
85
|
+
continue;
|
|
86
|
+
if (!variants.dev && !variants.deploy) {
|
|
87
|
+
notStatic(`Contributor '${name}' declares neither dev nor deploy`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const manifest = (state.contributorsManifest ??= {});
|
|
91
|
+
if (manifest[name]) {
|
|
92
|
+
fail(ErrorCode.CONTRIBUTOR_NAME_DUPLICATED, `Contributor '${name}' is defined twice. A contributor name belongs to one defineContributors entry.`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
logger.debug(`• Found ${DEFINE_CONTRIBUTORS}: ${name}`);
|
|
96
|
+
manifest[name] = variants;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -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 `definePlugin({ 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 `definePlugin` 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 definePlugin 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 definePlugin 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 `definePlugin({ 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 !== 'definePlugin' && 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 definePlugin 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(`definePlugin'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(`definePlugin'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(`definePlugin 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, `definePlugin declares two screens at '${screen.path}'; each screen needs its own path`);
|
|
240
|
+
}
|
|
241
|
+
seen.add(screen.path);
|
|
242
|
+
}
|
|
243
|
+
logger.debug(`• Found definePlugin: ${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 `definePlugin` 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, `definePlugin 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(`definePlugin 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;
|
|
@@ -87,8 +87,7 @@ export function addWireAddon(node, state, logger) {
|
|
|
87
87
|
if (!ts.isCallExpression(node))
|
|
88
88
|
return;
|
|
89
89
|
const { expression, arguments: args } = node;
|
|
90
|
-
if (!ts.isIdentifier(expression) ||
|
|
91
|
-
expression.text !== 'wireAddon')
|
|
90
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'wireAddon')
|
|
92
91
|
return;
|
|
93
92
|
const [firstArg] = args;
|
|
94
93
|
if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
|
|
@@ -114,6 +113,7 @@ export function addWireAddon(node, state, logger) {
|
|
|
114
113
|
let contentBucket;
|
|
115
114
|
let contentGrants;
|
|
116
115
|
let globalContent;
|
|
116
|
+
let contributors;
|
|
117
117
|
for (const prop of firstArg.properties) {
|
|
118
118
|
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
|
|
119
119
|
continue;
|
|
@@ -217,6 +217,14 @@ export function addWireAddon(node, state, logger) {
|
|
|
217
217
|
ts.isObjectLiteralExpression(prop.initializer)) {
|
|
218
218
|
contentGrants = parseStringRecord(prop.initializer);
|
|
219
219
|
}
|
|
220
|
+
else if (key === 'contributors') {
|
|
221
|
+
contributors = parseStringArray(prop.initializer);
|
|
222
|
+
// An unreadable list read as "none" would silently drop services the
|
|
223
|
+
// project asked for.
|
|
224
|
+
if (contributors === undefined) {
|
|
225
|
+
logger.critical(ErrorCode.ADDON_CONTRIBUTORS_NOT_STATIC, `wireAddon's contributors must be an array of string literals, got: ${prop.initializer.getText()}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
220
228
|
else if (key === 'globalContent') {
|
|
221
229
|
globalContent = ts.isStringLiteral(prop.initializer)
|
|
222
230
|
? prop.initializer.text
|
|
@@ -248,6 +256,7 @@ export function addWireAddon(node, state, logger) {
|
|
|
248
256
|
contentBucket,
|
|
249
257
|
contentGrants,
|
|
250
258
|
globalContent,
|
|
259
|
+
...(contributors ? { contributors } : {}),
|
|
251
260
|
});
|
|
252
261
|
state.rpc.usedAddons.add(name);
|
|
253
262
|
state.rpc.wireAddonFiles.add(node.getSourceFile().fileName);
|
|
@@ -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;
|