@pikku/inspector 0.12.109 → 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.
Files changed (46) hide show
  1. package/dist/add/add-channel.js +8 -0
  2. package/dist/add/add-file-with-factory.d.ts +7 -1
  3. package/dist/add/add-file-with-factory.js +63 -2
  4. package/dist/add/add-functions.js +32 -1
  5. package/dist/add/add-pikku-studio.d.ts +15 -0
  6. package/dist/add/add-pikku-studio.js +307 -0
  7. package/dist/add/add-rpc-invocations.d.ts +1 -1
  8. package/dist/add/add-rpc-invocations.js +14 -2
  9. package/dist/add/add-wire-remote-addon.js +25 -4
  10. package/dist/add/check-disabled-references.d.ts +27 -0
  11. package/dist/add/check-disabled-references.js +226 -0
  12. package/dist/error-codes.d.ts +14 -0
  13. package/dist/error-codes.js +14 -0
  14. package/dist/finalize.js +3 -1
  15. package/dist/index.d.ts +6 -1
  16. package/dist/index.js +3 -0
  17. package/dist/initial-state.js +1 -0
  18. package/dist/inspector.js +4 -0
  19. package/dist/lite.d.ts +7 -1
  20. package/dist/lite.js +5 -1
  21. package/dist/types.d.ts +65 -13
  22. package/dist/utils/addon-dependencies.d.ts +43 -0
  23. package/dist/utils/addon-dependencies.js +91 -0
  24. package/dist/utils/contract-hashes.js +2 -2
  25. package/dist/utils/find-unused-functions.d.ts +20 -0
  26. package/dist/utils/find-unused-functions.js +132 -0
  27. package/dist/utils/find-wire-remote-addon-specs.d.ts +17 -0
  28. package/dist/utils/find-wire-remote-addon-specs.js +53 -0
  29. package/dist/utils/get-property-value.js +4 -1
  30. package/dist/utils/load-addon-functions-meta.js +69 -46
  31. package/dist/utils/post-process.js +58 -8
  32. package/dist/utils/remote-addon-sdk.d.ts +57 -0
  33. package/dist/utils/remote-addon-sdk.js +111 -0
  34. package/dist/utils/resolve-addon-package.d.ts +1 -0
  35. package/dist/utils/resolve-addon-package.js +1 -1
  36. package/dist/utils/resolve-versions.js +18 -0
  37. package/dist/utils/serialize-inspector-state.d.ts +5 -2
  38. package/dist/utils/serialize-inspector-state.js +12 -4
  39. package/dist/utils/validate-addon-zod-contract.d.ts +23 -0
  40. package/dist/utils/validate-addon-zod-contract.js +70 -0
  41. package/dist/utils/validate-exposed-functions-gated.d.ts +3 -1
  42. package/dist/utils/validate-exposed-functions-gated.js +15 -8
  43. package/dist/visit.js +6 -3
  44. package/package.json +3 -3
  45. package/dist/add/add-define-screens.d.ts +0 -8
  46. package/dist/add/add-define-screens.js +0 -140
@@ -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
- if (node.initializer && ts.isCallExpression(node.initializer)) {
130
- const callExpression = node.initializer;
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,8 @@ 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';
44
+ import { addonZodContractViolation } from '../utils/validate-addon-zod-contract.js';
43
45
  const isValidVariableName = (name) => {
44
46
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
45
47
  return regex.test(name);
@@ -381,7 +383,23 @@ export const addFunctions = (logger, node, checker, state, options) => {
381
383
  if (args.length === 0)
382
384
  return;
383
385
  let { pikkuFuncId, name, explicitName, exportedName } = extractFunctionName(node, checker, state.rootDir);
384
- if (!pikkuFuncId || pikkuFuncId.startsWith('__temp_')) {
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_')) {
385
403
  return;
386
404
  }
387
405
  // pikku's own scenario instrumentation, which older projects still carry as a
@@ -887,6 +905,19 @@ export const addFunctions = (logger, node, checker, state, options) => {
887
905
  if (inputNames.length > 1) {
888
906
  logger.warn('More than one input type detected, only the first one will be used as a schema.');
889
907
  }
908
+ if (options.isAddon && remote && !notRpc) {
909
+ const violation = addonZodContractViolation(state.functions.typesMap, {
910
+ name,
911
+ usesGenerics: (typeArguments?.length ?? 0) > 0,
912
+ hasInputSchema: !!inputSchemaRef,
913
+ hasOutputSchema: !!outputSchemaRef,
914
+ inputNames,
915
+ outputNames,
916
+ });
917
+ if (violation) {
918
+ logger.critical(ErrorCode.ADDON_REMOTE_CONTRACT_NOT_ZOD, violation);
919
+ }
920
+ }
890
921
  // Store the input type for later use
891
922
  if (inputTypes.length > 0) {
892
923
  state.typesLookup.set(pikkuFuncId, inputTypes);
@@ -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
+ }
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
2
2
  import type { InspectorState, InspectorLogger } from '../types.js';
3
3
  /**
4
4
  * The RPC names a function handler calls through `rpc.invoke(...)` /
5
- * `rpc.remote(...)`, read from its own body.
5
+ * `rpc.mesh(...)`, read from its own body.
6
6
  *
7
7
  * This is what the deploy planner uses to bind a unit to the units its
8
8
  * functions call: without it, a caller and callee split into different units
@@ -44,7 +44,7 @@ function isRpcReceiver(node) {
44
44
  * is not bundled in the caller's deploy unit, cross to another unit through the
45
45
  * `DeploymentService`.
46
46
  */
47
- const CROSS_UNIT_RPC_METHODS = new Set(['invoke', 'remote']);
47
+ const CROSS_UNIT_RPC_METHODS = new Set(['invoke', 'mesh']);
48
48
  /** The name argument when it is statically known: `'x'`, `"x"` or `` `x` ``. */
49
49
  function literalRpcName(arg) {
50
50
  if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
@@ -54,7 +54,7 @@ function literalRpcName(arg) {
54
54
  }
55
55
  /**
56
56
  * The RPC names a function handler calls through `rpc.invoke(...)` /
57
- * `rpc.remote(...)`, read from its own body.
57
+ * `rpc.mesh(...)`, read from its own body.
58
58
  *
59
59
  * This is what the deploy planner uses to bind a unit to the units its
60
60
  * functions call: without it, a caller and callee split into different units
@@ -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;
@@ -1,4 +1,6 @@
1
1
  import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ import { parseRemoteAddonPackage } from '../utils/remote-addon-sdk.js';
2
4
  /**
3
5
  * Detect wireRemoteAddon({ name: '...', package: '...' }) call expressions and
4
6
  * record the namespace as a **remote** addon declaration. Only name + package
@@ -17,6 +19,7 @@ export function addWireRemoteAddon(node, state, logger) {
17
19
  return;
18
20
  let name;
19
21
  let pkg;
22
+ let pkgNode;
20
23
  // The auth binding is a runtime closure; we only extract the statically
21
24
  // knowable slot names so `pikku verify` can check they exist in the
22
25
  // consumer's own wirings. `hasAuth` distinguishes "public" (omitted) from
@@ -31,8 +34,11 @@ export function addWireRemoteAddon(node, state, logger) {
31
34
  if (key === 'name' && ts.isStringLiteral(prop.initializer)) {
32
35
  name = prop.initializer.text;
33
36
  }
34
- else if (key === 'package' && ts.isStringLiteral(prop.initializer)) {
35
- pkg = prop.initializer.text;
37
+ else if (key === 'package') {
38
+ pkgNode = prop.initializer;
39
+ if (ts.isStringLiteralLike(prop.initializer)) {
40
+ pkg = prop.initializer.text;
41
+ }
36
42
  }
37
43
  else if (key === 'auth' &&
38
44
  ts.isObjectLiteralExpression(prop.initializer)) {
@@ -52,11 +58,26 @@ export function addWireRemoteAddon(node, state, logger) {
52
58
  }
53
59
  }
54
60
  }
61
+ if (pkgNode && pkg === undefined) {
62
+ logger.critical(ErrorCode.REMOTE_ADDON_PACKAGE_NOT_STATIC, `wireRemoteAddon(${name ? `'${name}'` : '…'}) in ${node.getSourceFile().fileName} sets package to '${pkgNode.getText()}', which is not a string literal. Write the package (and its version range) inline, e.g. package: '@scope/addon@^1.0.0', so it can be read without running the file.`);
63
+ return;
64
+ }
55
65
  if (!name || !pkg)
56
66
  return;
57
- logger.debug(`• Found wireRemoteAddon: ${name} → ${pkg} (remote)`);
67
+ let packageName;
68
+ let range;
69
+ try {
70
+ ;
71
+ ({ name: packageName, range } = parseRemoteAddonPackage(pkg));
72
+ }
73
+ catch (e) {
74
+ logger.critical(ErrorCode.REMOTE_ADDON_PACKAGE_INVALID, `wireRemoteAddon('${name}'): ${e instanceof Error ? e.message : e}`);
75
+ return;
76
+ }
77
+ logger.debug(`• Found wireRemoteAddon: ${name} → ${packageName}${range ? `@${range}` : ''} (remote)`);
58
78
  state.rpc.wireAddonDeclarations.set(name, {
59
- package: pkg,
79
+ package: packageName,
80
+ ...(range ? { range } : {}),
60
81
  file: node.getSourceFile().fileName,
61
82
  remote: true,
62
83
  hasAuth,
@@ -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;