@pikku/inspector 0.12.21 → 0.12.22
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 +20 -0
- package/dist/add/add-functions.js +16 -8
- package/dist/add/add-workflow.js +16 -2
- package/dist/error-codes.d.ts +12 -0
- package/dist/index.d.ts +1 -0
- package/dist/inspector.js +5 -1
- package/dist/types.d.ts +10 -1
- package/dist/utils/extract-node-value.js +19 -2
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +15 -0
- package/package.json +2 -2
- package/src/add/add-auth.test.ts +3 -0
- package/src/add/add-cli-renderers.test.ts +1 -0
- package/src/add/add-functions.test.ts +13 -0
- package/src/add/add-functions.ts +14 -10
- package/src/add/add-workflow-fanout.test.ts +106 -0
- package/src/add/add-workflow.test.ts +3 -0
- package/src/add/add-workflow.ts +16 -2
- package/src/add/pii-check.test.ts +4 -0
- package/src/add/wire-name-literal.test.ts +3 -0
- package/src/error-codes.ts +14 -0
- package/src/index.ts +1 -0
- package/src/inspector.ts +8 -1
- package/src/types.ts +10 -1
- package/src/utils/extract-node-value.test.ts +49 -1
- package/src/utils/extract-node-value.ts +19 -2
- package/src/utils/filter-inspector-state.test.ts +1 -0
- package/src/utils/filter-utils.test.ts +1 -0
- package/src/utils/resolve-versions.test.ts +1 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +16 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## 0.12.22
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 06234a9: Fix DSL `Promise.all` fanout silently failing to register its child RPC (causing a runtime "Function not found").
|
|
6
|
+
|
|
7
|
+
Two distinct causes are addressed:
|
|
8
|
+
- A fanout/group captured into a variable (`const results = await Promise.all(array.map(e => workflow.do(...)))`) was dropped entirely, because the `const`-declaration path had no `Promise.all` branch — fanout handling only ran on the bare/assignment path. The declaration path now extracts fanout and parallel groups too.
|
|
9
|
+
- `extractStringLiteral` threw on a `+` concatenation with a non-static operand (e.g. `'Enrich ' + (e.id ?? e.name)`), unlike a template literal (`` `Enrich ${e.id ?? e.name}` ``) which never threw. The throw was uncaught while scanning workflow invocations and aborted the run. The `+` branch now falls back to `${...}` placeholders to match template literals, and a step's cosmetic display name can no longer block RPC registration.
|
|
10
|
+
|
|
11
|
+
- 8e72c93: Exclude `node_modules` from inspector source scanning. A locally-installed addon (under the project's `node_modules`) is a dependency, not project source — scanning it double-counted the addon's own application types (`CoreConfig`/`CoreServices`/`CoreSingletonServices`) and failed `pikku all` with "More than one … found". Addons still contribute via their generated metadata, not by being re-scanned as source.
|
|
12
|
+
- 6645e7a: Add a severity model for coded diagnostics so security findings can surface without blocking the dev server.
|
|
13
|
+
- `InspectorLogger` gains `diagnostic({ severity, code, message })` (`severity: 'warn' | 'error' | 'critical'`). `critical(code, message)` is now sugar for `diagnostic({ severity: 'critical', ... })`.
|
|
14
|
+
- The CLI fails the build only on `critical` diagnostics by default. New global flags `--fail-on-error` and `--fail-on-warn` (implies `--fail-on-error`) opt into stricter gating; `--fail-on-critical` is always on.
|
|
15
|
+
- Data-classification leaks (`PKU910`) are now emitted at `error` severity instead of `critical`. They are still printed, but no longer abort `pikku all` / the dev server — pass `--fail-on-error` (e.g. at deploy) to make them blocking and recommend a fix.
|
|
16
|
+
- Contract-immutability drift (`PKU861`) during `pikku versions update` (run inside `pikku all`) no longer calls `process.exit(1)`. It is surfaced as an `error` diagnostic and skips saving the manifest, so a stale baseline can't crash-loop the dev server. `pikku versions check` remains the hard gate, and `--fail-on-error` makes `pikku all` block on it at deploy.
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [6bca38f]
|
|
19
|
+
- @pikku/core@0.12.35
|
|
20
|
+
|
|
1
21
|
## 0.12.21
|
|
2
22
|
|
|
3
23
|
### Patch Changes
|
|
@@ -660,16 +660,24 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
660
660
|
.filter((f) => f.classification === 'private' || f.classification === 'pii')
|
|
661
661
|
.map((f) => f.path);
|
|
662
662
|
if (secretPaths.length > 0) {
|
|
663
|
-
logger.
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
`
|
|
663
|
+
logger.diagnostic({
|
|
664
|
+
severity: 'error',
|
|
665
|
+
code: ErrorCode.PII_IN_OUTPUT,
|
|
666
|
+
message: `Function '${name}' exposes secret-classified field(s) in its return type: ` +
|
|
667
|
+
secretPaths.map((p) => `'${p}'`).join(', ') +
|
|
668
|
+
`.\n Secret fields must never appear in function output. ` +
|
|
669
|
+
`Strip these fields before returning or change the column classification.`,
|
|
670
|
+
});
|
|
667
671
|
}
|
|
668
672
|
if (sessionless && privatePaths.length > 0) {
|
|
669
|
-
logger.
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
`
|
|
673
|
+
logger.diagnostic({
|
|
674
|
+
severity: 'error',
|
|
675
|
+
code: ErrorCode.PII_IN_OUTPUT,
|
|
676
|
+
message: `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
|
|
677
|
+
privatePaths.map((p) => `'${p}'`).join(', ') +
|
|
678
|
+
`.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
|
|
679
|
+
`Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
|
|
680
|
+
});
|
|
673
681
|
}
|
|
674
682
|
}
|
|
675
683
|
}
|
package/dist/add/add-workflow.js
CHANGED
|
@@ -5,6 +5,20 @@ import { ErrorCode } from '../error-codes.js';
|
|
|
5
5
|
import { extractStringLiteral, isStringLike, isFunctionLike, extractDescription, extractDuration, } from '../utils/extract-node-value.js';
|
|
6
6
|
import { getCommonWireMetaData, getPropertyValue, } from '../utils/get-property-value.js';
|
|
7
7
|
import { extractDSLWorkflow } from '../utils/workflow/dsl/extract-dsl-workflow.js';
|
|
8
|
+
import { getSourceText } from '../utils/workflow/dsl/patterns.js';
|
|
9
|
+
/**
|
|
10
|
+
* Extract a workflow step's display name without letting a non-static name
|
|
11
|
+
* (e.g. a function call) abort the scan. The step name is cosmetic, so a
|
|
12
|
+
* resolution failure must never prevent the RPC from being registered.
|
|
13
|
+
*/
|
|
14
|
+
function extractStepName(node, checker) {
|
|
15
|
+
try {
|
|
16
|
+
return extractStringLiteral(node, checker);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return getSourceText(node);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
8
22
|
/**
|
|
9
23
|
* Recursively check if any step has inline type (non-serializable)
|
|
10
24
|
*/
|
|
@@ -89,7 +103,7 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
|
|
|
89
103
|
const stepNameArg = args[0];
|
|
90
104
|
const secondArg = args[1];
|
|
91
105
|
const optionsArg = args.length >= 4 ? args[args.length - 1] : undefined;
|
|
92
|
-
const stepName =
|
|
106
|
+
const stepName = extractStepName(stepNameArg, checker);
|
|
93
107
|
const description = extractDescription(optionsArg, checker) ?? undefined;
|
|
94
108
|
// Determine form by checking 2nd argument type
|
|
95
109
|
if (isStringLike(secondArg, checker)) {
|
|
@@ -115,7 +129,7 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
|
|
|
115
129
|
// workflow.sleep(stepName, duration)
|
|
116
130
|
const stepNameArg = args[0];
|
|
117
131
|
const durationArg = args[1];
|
|
118
|
-
const stepName =
|
|
132
|
+
const stepName = extractStepName(stepNameArg, checker);
|
|
119
133
|
const duration = extractDuration(durationArg, checker);
|
|
120
134
|
steps.push({
|
|
121
135
|
type: 'sleep',
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -59,3 +59,15 @@ export declare enum ErrorCode {
|
|
|
59
59
|
WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901",
|
|
60
60
|
PII_IN_OUTPUT = "PKU910"
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Severity of a tracked, coded diagnostic. `critical` always blocks the build;
|
|
64
|
+
* `error`/`warn` only block when the CLI is told to via `--fail-on-error` /
|
|
65
|
+
* `--fail-on-warn` (default: critical only). All severities are still printed.
|
|
66
|
+
*/
|
|
67
|
+
export type DiagnosticSeverity = 'warn' | 'error' | 'critical';
|
|
68
|
+
/** A coded diagnostic emitted via `logger.diagnostic(...)`. */
|
|
69
|
+
export interface CodedDiagnostic {
|
|
70
|
+
severity: DiagnosticSeverity;
|
|
71
|
+
code: ErrorCode;
|
|
72
|
+
message: string;
|
|
73
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type { TypesMap } from './types-map.js';
|
|
|
3
3
|
export type * from './types.js';
|
|
4
4
|
export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js';
|
|
5
5
|
export { ErrorCode } from './error-codes.js';
|
|
6
|
+
export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
|
|
6
7
|
export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js';
|
|
7
8
|
export { serializeInspectorState, deserializeInspectorState, } from './utils/serialize-inspector-state.js';
|
|
8
9
|
export type { SerializableInspectorState } from './utils/serialize-inspector-state.js';
|
package/dist/inspector.js
CHANGED
|
@@ -209,9 +209,13 @@ export const inspect = async (logger, routeFiles, options = {}) => {
|
|
|
209
209
|
// Use provided rootDir or infer from source files
|
|
210
210
|
const rootDir = options.rootDir || findCommonAncestor(routeFiles);
|
|
211
211
|
const startSourceFiles = performance.now();
|
|
212
|
+
// node_modules under rootDir (e.g. a locally-installed addon) is a
|
|
213
|
+
// dependency, not project source — scanning it double-counts the addon's
|
|
214
|
+
// own application types (CoreConfig/Services/SingletonServices).
|
|
212
215
|
const sourceFiles = program
|
|
213
216
|
.getSourceFiles()
|
|
214
|
-
.filter((sf) => sf.fileName.startsWith(rootDir)
|
|
217
|
+
.filter((sf) => sf.fileName.startsWith(rootDir) &&
|
|
218
|
+
!sf.fileName.includes('/node_modules/'));
|
|
215
219
|
logger.debug(`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`);
|
|
216
220
|
const state = getInitialInspectorState(rootDir);
|
|
217
221
|
// First sweep: add all functions
|
package/dist/types.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { VariableDefinitions } from '@pikku/core/variable';
|
|
|
16
16
|
import type { TypesMap } from './types-map.js';
|
|
17
17
|
import type { FunctionsMeta, FunctionServicesMeta, FunctionWiresMeta, JSONValue } from '@pikku/core';
|
|
18
18
|
import type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js';
|
|
19
|
-
import type { ErrorCode } from './error-codes.js';
|
|
19
|
+
import type { ErrorCode, CodedDiagnostic } from './error-codes.js';
|
|
20
20
|
import type { VersionManifest, VersionValidateError } from './utils/contract-hashes.js';
|
|
21
21
|
import type { SerializedWorkflowGraphs } from './utils/workflow/graph/workflow-graph.types.js';
|
|
22
22
|
export type PathToNameAndType = Map<string, {
|
|
@@ -192,6 +192,15 @@ export interface InspectorLogger {
|
|
|
192
192
|
error: (message: string) => void;
|
|
193
193
|
warn: (message: string) => void;
|
|
194
194
|
debug: (message: string) => void;
|
|
195
|
+
/**
|
|
196
|
+
* Emit a tracked, coded diagnostic. It is recorded and printed; `error`/`warn`
|
|
197
|
+
* only block the build when the CLI is run with `--fail-on-error` /
|
|
198
|
+
* `--fail-on-warn` (default: critical only). Use this for issues worth
|
|
199
|
+
* surfacing (e.g. data-classification leaks) that should not stop the dev
|
|
200
|
+
* server from starting.
|
|
201
|
+
*/
|
|
202
|
+
diagnostic: (diagnostic: CodedDiagnostic) => void;
|
|
203
|
+
/** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
|
|
195
204
|
critical: (code: ErrorCode, message: string) => void;
|
|
196
205
|
hasCriticalErrors: () => boolean;
|
|
197
206
|
}
|
|
@@ -26,8 +26,8 @@ export function extractStringLiteral(node, checker) {
|
|
|
26
26
|
}
|
|
27
27
|
if (ts.isBinaryExpression(node) &&
|
|
28
28
|
node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
29
|
-
return (
|
|
30
|
-
|
|
29
|
+
return (extractConcatOperand(node.left, checker) +
|
|
30
|
+
extractConcatOperand(node.right, checker));
|
|
31
31
|
}
|
|
32
32
|
// Try to evaluate constant identifiers
|
|
33
33
|
if (ts.isIdentifier(node)) {
|
|
@@ -42,6 +42,23 @@ export function extractStringLiteral(node, checker) {
|
|
|
42
42
|
}
|
|
43
43
|
throw new Error('Unable to extract string literal from node');
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve one operand of a `+` string concatenation.
|
|
47
|
+
*
|
|
48
|
+
* An operand that can't be statically resolved (e.g. `a ?? b`) becomes a
|
|
49
|
+
* `${...}` placeholder rather than throwing — mirroring the TemplateExpression
|
|
50
|
+
* branch above, so `'x ' + expr` and `` `x ${expr}` `` produce the same string.
|
|
51
|
+
* This keeps an unresolvable display name from aborting the whole extraction.
|
|
52
|
+
*/
|
|
53
|
+
function extractConcatOperand(node, checker) {
|
|
54
|
+
try {
|
|
55
|
+
return extractStringLiteral(node, checker);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
const inner = ts.isParenthesizedExpression(node) ? node.expression : node;
|
|
59
|
+
return '${' + inner.getText() + '}';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
45
62
|
/**
|
|
46
63
|
* Check if node is string-like (string literal or template expression)
|
|
47
64
|
*/
|
|
@@ -255,6 +255,21 @@ function extractVariableDeclaration(statement, context) {
|
|
|
255
255
|
return step;
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
|
+
// Promise.all fanout/group captured into a variable
|
|
259
|
+
// (const results = await Promise.all(array.map(...)))
|
|
260
|
+
if (isParallelFanout(call) || isParallelGroup(call)) {
|
|
261
|
+
const step = isParallelFanout(call)
|
|
262
|
+
? extractParallelFanout(call, context)
|
|
263
|
+
: extractParallelGroup(call, context);
|
|
264
|
+
if (step) {
|
|
265
|
+
const type = context.checker.getTypeAtLocation(decl);
|
|
266
|
+
context.outputVars.set(varName, { type, node: decl });
|
|
267
|
+
if (isArrayType(type, context.checker)) {
|
|
268
|
+
context.arrayVars.add(varName);
|
|
269
|
+
}
|
|
270
|
+
return step;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
258
273
|
}
|
|
259
274
|
// Check for array.filter(...)
|
|
260
275
|
if (ts.isCallExpression(init)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.22",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
|
|
38
|
-
"@pikku/core": "^0.12.
|
|
38
|
+
"@pikku/core": "^0.12.35",
|
|
39
39
|
"openapi-types": "^12.1.3",
|
|
40
40
|
"path-to-regexp": "^8.3.0",
|
|
41
41
|
"ts-json-schema-generator": "^2.5.0",
|
package/src/add/add-auth.test.ts
CHANGED
|
@@ -13,6 +13,9 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
|
13
13
|
info: () => {},
|
|
14
14
|
warn: () => {},
|
|
15
15
|
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
16
19
|
critical: (code: ErrorCode, message: string) => {
|
|
17
20
|
criticals.push({ code, message })
|
|
18
21
|
},
|
|
@@ -39,6 +39,9 @@ describe('addFunctions duplicate name handling', () => {
|
|
|
39
39
|
info: () => {},
|
|
40
40
|
warn: () => {},
|
|
41
41
|
error: () => {},
|
|
42
|
+
diagnostic: ({ code, message }) => {
|
|
43
|
+
criticals.push({ code, message })
|
|
44
|
+
},
|
|
42
45
|
critical: (code: ErrorCode, message: string) => {
|
|
43
46
|
criticals.push({ code, message })
|
|
44
47
|
},
|
|
@@ -91,6 +94,9 @@ describe('addFunctions duplicate name handling', () => {
|
|
|
91
94
|
info: () => {},
|
|
92
95
|
warn: () => {},
|
|
93
96
|
error: () => {},
|
|
97
|
+
diagnostic: ({ code, message }) => {
|
|
98
|
+
criticals.push({ code, message })
|
|
99
|
+
},
|
|
94
100
|
critical: (code: ErrorCode, message: string) => {
|
|
95
101
|
criticals.push({ code, message })
|
|
96
102
|
},
|
|
@@ -142,6 +148,7 @@ describe('addFunctions duplicate name handling', () => {
|
|
|
142
148
|
info: () => {},
|
|
143
149
|
warn: () => {},
|
|
144
150
|
error: () => {},
|
|
151
|
+
diagnostic: () => {},
|
|
145
152
|
critical: () => {},
|
|
146
153
|
hasCriticalErrors: () => false,
|
|
147
154
|
}
|
|
@@ -204,6 +211,9 @@ describe('addFunctions duplicate name handling', () => {
|
|
|
204
211
|
info: () => {},
|
|
205
212
|
warn: () => {},
|
|
206
213
|
error: () => {},
|
|
214
|
+
diagnostic: ({ code, message }) => {
|
|
215
|
+
criticals.push({ code, message })
|
|
216
|
+
},
|
|
207
217
|
critical: (code: ErrorCode, message: string) => {
|
|
208
218
|
criticals.push({ code, message })
|
|
209
219
|
},
|
|
@@ -250,6 +260,7 @@ describe('addFunctions implementationHash', () => {
|
|
|
250
260
|
info: () => {},
|
|
251
261
|
warn: () => {},
|
|
252
262
|
error: () => {},
|
|
263
|
+
diagnostic: () => {},
|
|
253
264
|
critical: () => {},
|
|
254
265
|
hasCriticalErrors: () => false,
|
|
255
266
|
}
|
|
@@ -296,6 +307,7 @@ describe('addFunctions implementationHash', () => {
|
|
|
296
307
|
info: () => {},
|
|
297
308
|
warn: () => {},
|
|
298
309
|
error: () => {},
|
|
310
|
+
diagnostic: () => {},
|
|
299
311
|
critical: () => {},
|
|
300
312
|
hasCriticalErrors: () => false,
|
|
301
313
|
}
|
|
@@ -349,6 +361,7 @@ describe('pikkuChannelConnectionFunc generic mapping', () => {
|
|
|
349
361
|
info: () => {},
|
|
350
362
|
warn: () => {},
|
|
351
363
|
error: () => {},
|
|
364
|
+
diagnostic: () => {},
|
|
352
365
|
critical: () => {},
|
|
353
366
|
hasCriticalErrors: () => false,
|
|
354
367
|
}
|
package/src/add/add-functions.ts
CHANGED
|
@@ -931,23 +931,27 @@ export const addFunctions: AddWiring = (
|
|
|
931
931
|
.map((f) => f.path)
|
|
932
932
|
|
|
933
933
|
if (secretPaths.length > 0) {
|
|
934
|
-
logger.
|
|
935
|
-
|
|
936
|
-
|
|
934
|
+
logger.diagnostic({
|
|
935
|
+
severity: 'error',
|
|
936
|
+
code: ErrorCode.PII_IN_OUTPUT,
|
|
937
|
+
message:
|
|
938
|
+
`Function '${name}' exposes secret-classified field(s) in its return type: ` +
|
|
937
939
|
secretPaths.map((p) => `'${p}'`).join(', ') +
|
|
938
940
|
`.\n Secret fields must never appear in function output. ` +
|
|
939
|
-
`Strip these fields before returning or change the column classification
|
|
940
|
-
)
|
|
941
|
+
`Strip these fields before returning or change the column classification.`,
|
|
942
|
+
})
|
|
941
943
|
}
|
|
942
944
|
|
|
943
945
|
if (sessionless && privatePaths.length > 0) {
|
|
944
|
-
logger.
|
|
945
|
-
|
|
946
|
-
|
|
946
|
+
logger.diagnostic({
|
|
947
|
+
severity: 'error',
|
|
948
|
+
code: ErrorCode.PII_IN_OUTPUT,
|
|
949
|
+
message:
|
|
950
|
+
`Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
|
|
947
951
|
privatePaths.map((p) => `'${p}'`).join(', ') +
|
|
948
952
|
`.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
|
|
949
|
-
`Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly
|
|
950
|
-
)
|
|
953
|
+
`Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
|
|
954
|
+
})
|
|
951
955
|
}
|
|
952
956
|
}
|
|
953
957
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { InspectorLogger } from '../types.js'
|
|
8
|
+
|
|
9
|
+
function makeLogger(
|
|
10
|
+
criticals: Array<{ code: string; message: string }>
|
|
11
|
+
): InspectorLogger {
|
|
12
|
+
return {
|
|
13
|
+
debug: () => {},
|
|
14
|
+
info: () => {},
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
diagnostic: ({ code, message }) => {
|
|
18
|
+
criticals.push({ code, message })
|
|
19
|
+
},
|
|
20
|
+
critical: (code: any, message: string) => {
|
|
21
|
+
criticals.push({ code, message })
|
|
22
|
+
},
|
|
23
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STEP_FILE = [
|
|
28
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
29
|
+
'export const processEventLeadsStep = pikkuSessionlessFunc({',
|
|
30
|
+
' func: async ({ logger }) => ({ persistedCount: 1 }),',
|
|
31
|
+
'})',
|
|
32
|
+
].join('\n')
|
|
33
|
+
|
|
34
|
+
describe('addWorkflow — Promise.all fanout RPC detection', () => {
|
|
35
|
+
test('registers fanout RPC when captured with `const x = await Promise.all(map(...))`', async () => {
|
|
36
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fanout-const-'))
|
|
37
|
+
const wfFile = join(rootDir, 'leads.workflow.ts')
|
|
38
|
+
const stepFile = join(rootDir, 'leads.steps.ts')
|
|
39
|
+
|
|
40
|
+
await writeFile(stepFile, STEP_FILE)
|
|
41
|
+
await writeFile(
|
|
42
|
+
wfFile,
|
|
43
|
+
[
|
|
44
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
45
|
+
'export const extractLeadsWorkflow = pikkuWorkflowFunc(async (_, data, { workflow }) => {',
|
|
46
|
+
' const events = [{ id: "a", name: "x" }]',
|
|
47
|
+
' const processed = await Promise.all(',
|
|
48
|
+
' events.map((event) =>',
|
|
49
|
+
" workflow.do(`Enrich event ${event.id ?? event.name}`, 'processEventLeadsStep', { event })",
|
|
50
|
+
' )',
|
|
51
|
+
' )',
|
|
52
|
+
' return { count: processed.length }',
|
|
53
|
+
'})',
|
|
54
|
+
].join('\n')
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
58
|
+
try {
|
|
59
|
+
const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
|
|
60
|
+
rootDir,
|
|
61
|
+
})
|
|
62
|
+
assert.ok(
|
|
63
|
+
state.rpc.invokedFunctions.has('processEventLeadsStep'),
|
|
64
|
+
'processEventLeadsStep should be registered when fanout is captured with const'
|
|
65
|
+
)
|
|
66
|
+
} finally {
|
|
67
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('registers fanout RPC with string-concatenation (`+`) step name, same as template literal', async () => {
|
|
72
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fanout-concat-'))
|
|
73
|
+
const wfFile = join(rootDir, 'leads.workflow.ts')
|
|
74
|
+
const stepFile = join(rootDir, 'leads.steps.ts')
|
|
75
|
+
|
|
76
|
+
await writeFile(stepFile, STEP_FILE)
|
|
77
|
+
await writeFile(
|
|
78
|
+
wfFile,
|
|
79
|
+
[
|
|
80
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
81
|
+
'export const extractLeadsWorkflow = pikkuWorkflowFunc(async (_, data, { workflow }) => {',
|
|
82
|
+
' const events = [{ id: "a", name: "x" }]',
|
|
83
|
+
' await Promise.all(',
|
|
84
|
+
' events.map((event) =>',
|
|
85
|
+
" workflow.do('Enrich event ' + (event.id ?? event.name), 'processEventLeadsStep', { event })",
|
|
86
|
+
' )',
|
|
87
|
+
' )',
|
|
88
|
+
' return { ok: true }',
|
|
89
|
+
'})',
|
|
90
|
+
].join('\n')
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
94
|
+
try {
|
|
95
|
+
const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
|
|
96
|
+
rootDir,
|
|
97
|
+
})
|
|
98
|
+
assert.ok(
|
|
99
|
+
state.rpc.invokedFunctions.has('processEventLeadsStep'),
|
|
100
|
+
'processEventLeadsStep should be registered even when the step name uses `+` concatenation with a non-static operand'
|
|
101
|
+
)
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
})
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -16,6 +16,20 @@ import {
|
|
|
16
16
|
getPropertyValue,
|
|
17
17
|
} from '../utils/get-property-value.js'
|
|
18
18
|
import { extractDSLWorkflow } from '../utils/workflow/dsl/extract-dsl-workflow.js'
|
|
19
|
+
import { getSourceText } from '../utils/workflow/dsl/patterns.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract a workflow step's display name without letting a non-static name
|
|
23
|
+
* (e.g. a function call) abort the scan. The step name is cosmetic, so a
|
|
24
|
+
* resolution failure must never prevent the RPC from being registered.
|
|
25
|
+
*/
|
|
26
|
+
function extractStepName(node: ts.Node, checker: ts.TypeChecker): string {
|
|
27
|
+
try {
|
|
28
|
+
return extractStringLiteral(node, checker)
|
|
29
|
+
} catch {
|
|
30
|
+
return getSourceText(node)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
|
|
20
34
|
/**
|
|
21
35
|
* Recursively check if any step has inline type (non-serializable)
|
|
@@ -99,7 +113,7 @@ function getWorkflowInvocations(
|
|
|
99
113
|
const optionsArg =
|
|
100
114
|
args.length >= 4 ? args[args.length - 1] : undefined
|
|
101
115
|
|
|
102
|
-
const stepName =
|
|
116
|
+
const stepName = extractStepName(stepNameArg, checker)
|
|
103
117
|
const description =
|
|
104
118
|
extractDescription(optionsArg, checker) ?? undefined
|
|
105
119
|
|
|
@@ -126,7 +140,7 @@ function getWorkflowInvocations(
|
|
|
126
140
|
const stepNameArg = args[0]
|
|
127
141
|
const durationArg = args[1]
|
|
128
142
|
|
|
129
|
-
const stepName =
|
|
143
|
+
const stepName = extractStepName(stepNameArg, checker)
|
|
130
144
|
const duration = extractDuration(durationArg, checker)
|
|
131
145
|
|
|
132
146
|
steps.push({
|
|
@@ -10,12 +10,16 @@ import type { InspectorLogger } from '../types.js'
|
|
|
10
10
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
12
|
function makeLogger() {
|
|
13
|
+
// Collects every coded diagnostic regardless of severity. PKU910 is now
|
|
14
|
+
// emitted at 'error' severity (surface, don't block the dev server) so it
|
|
15
|
+
// arrives via `diagnostic`, not `critical`.
|
|
13
16
|
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
14
17
|
const logger: InspectorLogger = {
|
|
15
18
|
debug: () => {},
|
|
16
19
|
info: () => {},
|
|
17
20
|
warn: () => {},
|
|
18
21
|
error: () => {},
|
|
22
|
+
diagnostic: ({ code, message }) => criticals.push({ code, message }),
|
|
19
23
|
critical: (code, message) => criticals.push({ code, message }),
|
|
20
24
|
hasCriticalErrors: () => criticals.length > 0,
|
|
21
25
|
}
|
|
@@ -13,6 +13,9 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
|
13
13
|
info: () => {},
|
|
14
14
|
warn: () => {},
|
|
15
15
|
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
16
19
|
critical: (code: ErrorCode, message: string) => {
|
|
17
20
|
criticals.push({ code, message })
|
|
18
21
|
},
|
package/src/error-codes.ts
CHANGED
|
@@ -85,3 +85,17 @@ export enum ErrorCode {
|
|
|
85
85
|
// Data classification errors
|
|
86
86
|
PII_IN_OUTPUT = 'PKU910',
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Severity of a tracked, coded diagnostic. `critical` always blocks the build;
|
|
91
|
+
* `error`/`warn` only block when the CLI is told to via `--fail-on-error` /
|
|
92
|
+
* `--fail-on-warn` (default: critical only). All severities are still printed.
|
|
93
|
+
*/
|
|
94
|
+
export type DiagnosticSeverity = 'warn' | 'error' | 'critical'
|
|
95
|
+
|
|
96
|
+
/** A coded diagnostic emitted via `logger.diagnostic(...)`. */
|
|
97
|
+
export interface CodedDiagnostic {
|
|
98
|
+
severity: DiagnosticSeverity
|
|
99
|
+
code: ErrorCode
|
|
100
|
+
message: string
|
|
101
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type { TypesMap } from './types-map.js'
|
|
|
3
3
|
export type * from './types.js'
|
|
4
4
|
export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js'
|
|
5
5
|
export { ErrorCode } from './error-codes.js'
|
|
6
|
+
export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js'
|
|
6
7
|
export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js'
|
|
7
8
|
export {
|
|
8
9
|
serializeInspectorState,
|
package/src/inspector.ts
CHANGED
|
@@ -256,9 +256,16 @@ export const inspect = async (
|
|
|
256
256
|
const rootDir = options.rootDir || findCommonAncestor(routeFiles)
|
|
257
257
|
|
|
258
258
|
const startSourceFiles = performance.now()
|
|
259
|
+
// node_modules under rootDir (e.g. a locally-installed addon) is a
|
|
260
|
+
// dependency, not project source — scanning it double-counts the addon's
|
|
261
|
+
// own application types (CoreConfig/Services/SingletonServices).
|
|
259
262
|
const sourceFiles = program
|
|
260
263
|
.getSourceFiles()
|
|
261
|
-
.filter(
|
|
264
|
+
.filter(
|
|
265
|
+
(sf) =>
|
|
266
|
+
sf.fileName.startsWith(rootDir) &&
|
|
267
|
+
!sf.fileName.includes('/node_modules/')
|
|
268
|
+
)
|
|
262
269
|
logger.debug(
|
|
263
270
|
`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`
|
|
264
271
|
)
|
package/src/types.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
JSONValue,
|
|
26
26
|
} from '@pikku/core'
|
|
27
27
|
import type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
|
|
28
|
-
import type { ErrorCode } from './error-codes.js'
|
|
28
|
+
import type { ErrorCode, CodedDiagnostic } from './error-codes.js'
|
|
29
29
|
import type {
|
|
30
30
|
VersionManifest,
|
|
31
31
|
VersionValidateError,
|
|
@@ -238,6 +238,15 @@ export interface InspectorLogger {
|
|
|
238
238
|
error: (message: string) => void
|
|
239
239
|
warn: (message: string) => void
|
|
240
240
|
debug: (message: string) => void
|
|
241
|
+
/**
|
|
242
|
+
* Emit a tracked, coded diagnostic. It is recorded and printed; `error`/`warn`
|
|
243
|
+
* only block the build when the CLI is run with `--fail-on-error` /
|
|
244
|
+
* `--fail-on-warn` (default: critical only). Use this for issues worth
|
|
245
|
+
* surfacing (e.g. data-classification leaks) that should not stop the dev
|
|
246
|
+
* server from starting.
|
|
247
|
+
*/
|
|
248
|
+
diagnostic: (diagnostic: CodedDiagnostic) => void
|
|
249
|
+
/** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
|
|
241
250
|
critical: (code: ErrorCode, message: string) => void
|
|
242
251
|
hasCriticalErrors: () => boolean
|
|
243
252
|
}
|