@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 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.critical(ErrorCode.PII_IN_OUTPUT, `Function '${name}' exposes secret-classified field(s) in its return type: ` +
664
- secretPaths.map((p) => `'${p}'`).join(', ') +
665
- `.\n Secret fields must never appear in function output. ` +
666
- `Strip these fields before returning or change the column classification.`);
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.critical(ErrorCode.PII_IN_OUTPUT, `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
670
- privatePaths.map((p) => `'${p}'`).join(', ') +
671
- `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
672
- `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`);
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
  }
@@ -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 = extractStringLiteral(stepNameArg, checker);
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 = extractStringLiteral(stepNameArg, checker);
132
+ const stepName = extractStepName(stepNameArg, checker);
119
133
  const duration = extractDuration(durationArg, checker);
120
134
  steps.push({
121
135
  type: 'sleep',
@@ -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 (extractStringLiteral(node.left, checker) +
30
- extractStringLiteral(node.right, checker));
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.21",
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.32",
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",
@@ -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
  },
@@ -12,6 +12,7 @@ const makeLogger = () =>
12
12
  info: () => {},
13
13
  warn: () => {},
14
14
  error: () => {},
15
+ diagnostic: () => {},
15
16
  critical: () => {},
16
17
  hasCriticalErrors: () => false,
17
18
  }) satisfies InspectorLogger
@@ -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
  }
@@ -931,23 +931,27 @@ export const addFunctions: AddWiring = (
931
931
  .map((f) => f.path)
932
932
 
933
933
  if (secretPaths.length > 0) {
934
- logger.critical(
935
- ErrorCode.PII_IN_OUTPUT,
936
- `Function '${name}' exposes secret-classified field(s) in its return type: ` +
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.critical(
945
- ErrorCode.PII_IN_OUTPUT,
946
- `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
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
+ })
@@ -14,6 +14,9 @@ function makeLogger(
14
14
  info: () => {},
15
15
  warn: () => {},
16
16
  error: () => {},
17
+ diagnostic: ({ code, message }) => {
18
+ criticals.push({ code, message })
19
+ },
17
20
  critical: (code: any, message: string) => {
18
21
  criticals.push({ code, message })
19
22
  },
@@ -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 = extractStringLiteral(stepNameArg, checker)
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 = extractStringLiteral(stepNameArg, checker)
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
  },
@@ -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((sf) => sf.fileName.startsWith(rootDir))
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
  }