@pikku/inspector 0.12.14 → 0.12.17

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/CHANGELOG.md +33 -0
  2. package/dist/add/add-ai-agent.js +1 -1
  3. package/dist/add/add-channel.js +25 -7
  4. package/dist/add/add-functions.js +31 -13
  5. package/dist/add/add-gateway.js +1 -1
  6. package/dist/add/add-http-route.js +23 -1
  7. package/dist/add/add-mcp-prompt.js +1 -1
  8. package/dist/add/add-mcp-resource.js +1 -1
  9. package/dist/add/add-queue-worker.js +1 -1
  10. package/dist/add/add-schedule.js +1 -1
  11. package/dist/add/add-trigger.js +1 -1
  12. package/dist/add/add-workflow.js +1 -1
  13. package/dist/utils/check-pii-output.d.ts +9 -4
  14. package/dist/utils/check-pii-output.js +17 -7
  15. package/dist/utils/custom-types-generator.js +55 -4
  16. package/dist/utils/ensure-function-metadata.js +1 -1
  17. package/dist/utils/extract-node-value.d.ts +1 -1
  18. package/dist/utils/extract-node-value.js +10 -1
  19. package/dist/utils/get-property-value.d.ts +1 -1
  20. package/dist/utils/get-property-value.js +35 -9
  21. package/dist/utils/schema-generator.js +4 -2
  22. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +43 -9
  23. package/package.json +2 -2
  24. package/src/add/add-ai-agent.ts +1 -1
  25. package/src/add/add-channel.ts +37 -7
  26. package/src/add/add-functions.ts +47 -13
  27. package/src/add/add-gateway.ts +1 -1
  28. package/src/add/add-http-route.ts +26 -1
  29. package/src/add/add-mcp-prompt.ts +1 -1
  30. package/src/add/add-mcp-resource.ts +1 -1
  31. package/src/add/add-queue-worker.ts +1 -1
  32. package/src/add/add-schedule.ts +1 -1
  33. package/src/add/add-trigger.ts +1 -1
  34. package/src/add/add-workflow.test.ts +152 -0
  35. package/src/add/add-workflow.ts +2 -1
  36. package/src/add/pii-check.test.ts +70 -28
  37. package/src/utils/check-pii-output.ts +27 -11
  38. package/src/utils/custom-types-generator.test.ts +99 -0
  39. package/src/utils/custom-types-generator.ts +64 -4
  40. package/src/utils/ensure-function-metadata.ts +3 -1
  41. package/src/utils/extract-node-value.test.ts +12 -10
  42. package/src/utils/extract-node-value.ts +15 -1
  43. package/src/utils/get-property-value.ts +33 -13
  44. package/src/utils/schema-generator.ts +4 -2
  45. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +50 -11
  46. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## 0.12.17
2
+
3
+ ### Patch Changes
4
+
5
+ - 2cf67be: Add inline option to pikkuFunc/pikkuSessionlessFunc for workflow step dispatch
6
+
7
+ By default, workflow steps now run inline (no queue hop). Set inline: false on a function to force dispatch through the queue for that step.
8
+
9
+ - Updated dependencies [2cf67be]
10
+ - @pikku/core@0.12.28
11
+
12
+ ## 0.12.16
13
+
14
+ ### Patch Changes
15
+
16
+ - 646c5a8: Fix inspector failing to extract descriptions written as string concatenation (`+`). Descriptions like `'line one ' + 'line two'` are now correctly resolved to their full value. The `checker` parameter is also threaded through `getCommonWireMetaData` so all wiring types benefit from static string evaluation.
17
+
18
+ ## 0.12.15
19
+
20
+ ### Patch Changes
21
+
22
+ - 0db854e: Fix workflow DSL extractor treating `x = await workflow.do(...)` as a set-step when `x` was previously declared as `null`. The referenced function is now correctly registered in `invokedFunctions` and `internalFiles`, so it appears in the generated `pikku-functions.gen.ts`.
23
+ - 8249f6f: Fix `isStringLike` to unwrap type assertion expressions (`as T` / `<T>expr`) so that `workflow.do('step', 'rpcName' as any, data)` is correctly parsed as an RPC step rather than silently dropped as an inline step. Also removes the `as any` cast from the `Emails` step in `all.workflow.ts` now that the inspector handles it, and ensures `pikku all` generates email template artifacts.
24
+ - f373a87: Fix PKU910 classification semantics and Postgres annotation propagation.
25
+
26
+ **Inspector (`@pikku/inspector`):**
27
+ - `findPiiPaths()` now returns `ClassifiedField[]` (path + classification level) so `private`/`pii` and `secret` brands are distinguished
28
+ - `Secret<T>` fields are blocked in the output of all exposed functions (sessioned or not)
29
+ - `Private<T>` / `Pii<T>` fields are only blocked in sessionless functions — authenticated (sessioned) functions may return private-classified data to their callers
30
+
31
+ **CLI (`@pikku/cli`):**
32
+ - Fix missing `rootDir` in the Postgres `generateSchemaTypes` call — the annotations sidecar file (`db/annotations.gen.json`) was silently ignored during Postgres migrations, causing columns annotated `@public` to remain branded as `Private<T>` in the generated schema
33
+
1
34
  ## 0.12.14
2
35
 
3
36
  ### Patch Changes
@@ -159,7 +159,7 @@ export const addAIAgent = (logger, node, checker, state, options) => {
159
159
  if (ts.isObjectLiteralExpression(firstArg)) {
160
160
  const obj = firstArg;
161
161
  const nameValue = getPropertyValue(obj, 'name');
162
- const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'AI agent', nameValue, logger);
162
+ const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'AI agent', nameValue, logger, checker);
163
163
  if (disabled)
164
164
  return;
165
165
  const modelValue = getPropertyValue(obj, 'model');
@@ -165,7 +165,7 @@ export function addMessagesRoutes(logger, obj, state, checker) {
165
165
  if (fnMeta) {
166
166
  // Resolve middleware for this route
167
167
  const routeTags = ts.isObjectLiteralExpression(init)
168
- ? getCommonWireMetaData(init, 'Channel message', routeKey, logger).tags
168
+ ? getCommonWireMetaData(init, 'Channel message', routeKey, logger, checker).tags
169
169
  : undefined;
170
170
  const routeMiddleware = ts.isObjectLiteralExpression(init)
171
171
  ? resolveMiddleware(state, init, routeTags, checker)
@@ -187,7 +187,7 @@ export function addMessagesRoutes(logger, obj, state, checker) {
187
187
  if (fnMeta) {
188
188
  // Resolve middleware for this route
189
189
  const routeTags = ts.isObjectLiteralExpression(init)
190
- ? getCommonWireMetaData(init, 'Channel message', routeKey, logger).tags
190
+ ? getCommonWireMetaData(init, 'Channel message', routeKey, logger, checker).tags
191
191
  : undefined;
192
192
  const routeMiddleware = ts.isObjectLiteralExpression(init)
193
193
  ? resolveMiddleware(state, init, routeTags, checker)
@@ -220,7 +220,7 @@ export function addMessagesRoutes(logger, obj, state, checker) {
220
220
  if (fnMeta) {
221
221
  // Resolve middleware for this route
222
222
  const routeTags = ts.isObjectLiteralExpression(init)
223
- ? getCommonWireMetaData(init, 'Channel message', routeKey, logger).tags
223
+ ? getCommonWireMetaData(init, 'Channel message', routeKey, logger, checker).tags
224
224
  : undefined;
225
225
  const routeMiddleware = ts.isObjectLiteralExpression(init)
226
226
  ? resolveMiddleware(state, init, routeTags, checker)
@@ -239,7 +239,7 @@ export function addMessagesRoutes(logger, obj, state, checker) {
239
239
  if (fnMeta) {
240
240
  // Resolve middleware for this route
241
241
  const routeTags = ts.isObjectLiteralExpression(init)
242
- ? getCommonWireMetaData(init, 'Channel message', routeKey, logger).tags
242
+ ? getCommonWireMetaData(init, 'Channel message', routeKey, logger, checker).tags
243
243
  : undefined;
244
244
  const routeMiddleware = ts.isObjectLiteralExpression(init)
245
245
  ? resolveMiddleware(state, init, routeTags, checker)
@@ -303,7 +303,7 @@ export function addMessagesRoutes(logger, obj, state, checker) {
303
303
  if (fnMeta) {
304
304
  // Resolve middleware for this route
305
305
  const routeTags = ts.isObjectLiteralExpression(init)
306
- ? getCommonWireMetaData(init, 'Channel message', routeKey, logger).tags
306
+ ? getCommonWireMetaData(init, 'Channel message', routeKey, logger, checker).tags
307
307
  : undefined;
308
308
  const routeMiddleware = ts.isObjectLiteralExpression(init)
309
309
  ? resolveMiddleware(state, init, routeTags, checker)
@@ -332,7 +332,7 @@ export function addMessagesRoutes(logger, obj, state, checker) {
332
332
  // Resolve middleware and permissions for this route
333
333
  // Check if the route config is an object literal with middleware/permissions
334
334
  const routeTags = ts.isObjectLiteralExpression(init)
335
- ? getCommonWireMetaData(init, 'Channel message', routeKey, logger).tags
335
+ ? getCommonWireMetaData(init, 'Channel message', routeKey, logger, checker).tags
336
336
  : undefined;
337
337
  const routeMiddleware = ts.isObjectLiteralExpression(init)
338
338
  ? resolveMiddleware(state, init, routeTags, checker)
@@ -378,7 +378,7 @@ export const addChannel = (logger, node, checker, state, options) => {
378
378
  .keys.filter((k) => k.type === 'param')
379
379
  .map((k) => k.name)
380
380
  : [];
381
- const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Channel', name, logger);
381
+ const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Channel', name, logger, checker);
382
382
  if (disabled)
383
383
  return;
384
384
  const query = getPropertyValue(obj, 'query');
@@ -437,6 +437,24 @@ export const addChannel = (logger, node, checker, state, options) => {
437
437
  : null;
438
438
  state.serviceAggregation.usedFunctions.add(disconnectFuncId);
439
439
  }
440
+ // Synthesize function meta for connect/disconnect handlers that are defined
441
+ // with pikkuChannelConnectionFunc/pikkuChannelDisconnectionFunc (not pikkuFunc/
442
+ // pikkuSessionlessFunc), so the runtime has correct sessionless info without
443
+ // needing to inject it at runtime.
444
+ {
445
+ const routeAuth = getPropertyValue(obj, 'auth');
446
+ const sessionless = routeAuth === false ? true : undefined;
447
+ for (const funcId of [connectFuncId, disconnectFuncId]) {
448
+ if (funcId && !state.functions.meta[funcId]) {
449
+ state.functions.meta[funcId] = {
450
+ pikkuFuncId: funcId,
451
+ sessionless,
452
+ inputSchemaName: null,
453
+ outputSchemaName: null,
454
+ };
455
+ }
456
+ }
457
+ }
440
458
  if (message) {
441
459
  state.serviceAggregation.usedFunctions.add(message.pikkuFuncId);
442
460
  }
@@ -277,6 +277,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
277
277
  let deploy;
278
278
  let approvalRequired;
279
279
  let approvalDescription;
280
+ let inline;
280
281
  let version;
281
282
  let objectNode;
282
283
  let nodeDisplayName = null;
@@ -330,7 +331,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
330
331
  // Extract config properties if using object form
331
332
  if (ts.isObjectLiteralExpression(firstArg)) {
332
333
  objectNode = firstArg;
333
- const metadata = getCommonWireMetaData(firstArg, 'Function', name, logger);
334
+ const metadata = getCommonWireMetaData(firstArg, 'Function', name, logger, checker);
334
335
  if (metadata.disabled)
335
336
  return;
336
337
  title = metadata.title;
@@ -344,6 +345,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
344
345
  readonly_ = getPropertyValue(firstArg, 'readonly');
345
346
  deploy = getPropertyValue(firstArg, 'deploy');
346
347
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
348
+ inline = getPropertyValue(firstArg, 'inline');
347
349
  // Extract approvalDescription identifier reference
348
350
  for (const prop of firstArg.properties) {
349
351
  if (ts.isPropertyAssignment(prop) &&
@@ -627,22 +629,38 @@ export const addFunctions = (logger, node, checker, state, options) => {
627
629
  }
628
630
  }
629
631
  }
630
- // ── PII brand check ───────────────────────────────────────────────────────
631
- // Walk the function body's ACTUAL inferred return type looking for Private<T>
632
- // / Pii<T> / Secret<T> brands (__classification__ property). This runs for every function,
633
- // including those with a Zod output schema, because the TS return type
634
- // reflects what the body actually returns before any Zod coercion.
632
+ const sessionless = expression.text !== 'pikkuFunc';
633
+ // ── Classification brand check ─────────────────────────────────────────────
634
+ // Walk the function body's ACTUAL inferred return type looking for classification
635
+ // brands (__classification__ property on Private<T>, Pii<T>, Secret<T>).
636
+ //
637
+ // Semantics:
638
+ // secret → never returned by any exposed function (sessioned or not)
639
+ // private → only visible to authenticated (sessioned) users; ok for pikkuFunc
640
+ // public → safe for sessionless functions
635
641
  {
636
642
  const sig = checker.getSignatureFromDeclaration(handler);
637
643
  if (sig) {
638
644
  const rawRet = checker.getReturnTypeOfSignature(sig);
639
645
  const unwrapped = unwrapPromise(checker, rawRet);
640
- const piiPaths = findPiiPaths(checker, unwrapped);
641
- if (piiPaths.length > 0) {
642
- logger.critical(ErrorCode.PII_IN_OUTPUT, `Function '${name}' exposes PII-classified field(s) in its return type: ` +
643
- piiPaths.map((p) => `'${p}'`).join(', ') +
644
- `.\n Either strip these fields before returning or mark the column ` +
645
- `@public in the migration if it is safe to expose.`);
646
+ const classifiedFields = findPiiPaths(checker, unwrapped);
647
+ const secretPaths = classifiedFields
648
+ .filter((f) => f.classification === 'secret')
649
+ .map((f) => f.path);
650
+ const privatePaths = classifiedFields
651
+ .filter((f) => f.classification === 'private' || f.classification === 'pii')
652
+ .map((f) => f.path);
653
+ if (secretPaths.length > 0) {
654
+ logger.critical(ErrorCode.PII_IN_OUTPUT, `Function '${name}' exposes secret-classified field(s) in its return type: ` +
655
+ secretPaths.map((p) => `'${p}'`).join(', ') +
656
+ `.\n Secret fields must never appear in function output. ` +
657
+ `Strip these fields before returning or change the column classification.`);
658
+ }
659
+ if (sessionless && privatePaths.length > 0) {
660
+ logger.critical(ErrorCode.PII_IN_OUTPUT, `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
661
+ privatePaths.map((p) => `'${p}'`).join(', ') +
662
+ `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
663
+ `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`);
646
664
  }
647
665
  }
648
666
  }
@@ -675,7 +693,6 @@ export const addFunctions = (logger, node, checker, state, options) => {
675
693
  permissions = [...(permissions || []), ...newPermissions];
676
694
  }
677
695
  }
678
- const sessionless = expression.text !== 'pikkuFunc';
679
696
  const implementationHash = computeImplementationHash({
680
697
  wrapper: expression.text,
681
698
  handler,
@@ -701,6 +718,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
701
718
  deploy: deploy || undefined,
702
719
  approvalRequired: approvalRequired || undefined,
703
720
  approvalDescription: approvalDescription || undefined,
721
+ inline: inline === false ? false : undefined,
704
722
  implementationHash,
705
723
  version,
706
724
  title,
@@ -23,7 +23,7 @@ export const addGateway = (logger, node, checker, state, _options) => {
23
23
  const nameValue = getPropertyValue(obj, 'name');
24
24
  const typeValue = getPropertyValue(obj, 'type');
25
25
  const routeValue = getPropertyValue(obj, 'route');
26
- const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Gateway', nameValue, logger);
26
+ const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Gateway', nameValue, logger, checker);
27
27
  if (disabled)
28
28
  return;
29
29
  const funcInitializer = getPropertyAssignmentInitializer(obj, 'func', true, checker);
@@ -97,7 +97,7 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
97
97
  return;
98
98
  }
99
99
  // Get common metadata
100
- const { disabled, title, tags: routeTags, summary, description, errors, } = getCommonWireMetaData(obj, 'HTTP route', fullRoute, logger);
100
+ const { disabled, title, tags: routeTags, summary, description, errors, } = getCommonWireMetaData(obj, 'HTTP route', fullRoute, logger, checker);
101
101
  if (disabled)
102
102
  return;
103
103
  // Merge inherited tags with route tags
@@ -136,6 +136,28 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
136
136
  }
137
137
  }
138
138
  ensureFunctionMetadata(state, funcName, fullRoute, funcInitializer, checker, extracted.isHelper);
139
+ // Propagate sessionless from the addon target so the runtime can correctly
140
+ // determine whether a session is required for ref()-based routes.
141
+ if (refAddonTarget) {
142
+ const targetMeta = resolveFunctionMeta(state, refAddonTarget);
143
+ const inlineMeta = state.functions.meta[funcName];
144
+ if (targetMeta && inlineMeta && targetMeta.sessionless !== undefined) {
145
+ inlineMeta.sessionless = targetMeta.sessionless;
146
+ }
147
+ }
148
+ // For inline functions (e.g. oauth2 handlers), propagate sessionless: true
149
+ // when auth is explicitly disabled at the route or group level — the
150
+ // developer opted out of auth so no session is required.
151
+ {
152
+ const inlineMeta = state.functions.meta[funcName];
153
+ if (inlineMeta && inlineMeta.sessionless === undefined) {
154
+ const routeAuth = getPropertyValue(obj, 'auth');
155
+ const resolvedAuth = routeAuth === true || routeAuth === false ? routeAuth : inheritedAuth;
156
+ if (resolvedAuth === false) {
157
+ inlineMeta.sessionless = true;
158
+ }
159
+ }
160
+ }
139
161
  // Lookup existing function metadata
140
162
  const fnMeta = resolveFunctionMeta(state, funcName);
141
163
  if (!fnMeta) {
@@ -25,7 +25,7 @@ export const addMCPPrompt = (logger, node, checker, state, options) => {
25
25
  if (ts.isObjectLiteralExpression(firstArg)) {
26
26
  const obj = firstArg;
27
27
  const nameValue = getPropertyValue(obj, 'name');
28
- const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'MCP prompt', nameValue, logger);
28
+ const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'MCP prompt', nameValue, logger, checker);
29
29
  if (disabled)
30
30
  return;
31
31
  const funcInitializer = getPropertyAssignmentInitializer(obj, 'func', true, checker);
@@ -26,7 +26,7 @@ export const addMCPResource = (logger, node, checker, state, options) => {
26
26
  const obj = firstArg;
27
27
  const uriValue = getPropertyValue(obj, 'uri');
28
28
  const titleValue = getPropertyValue(obj, 'title');
29
- const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'MCP resource', uriValue, logger);
29
+ const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'MCP resource', uriValue, logger, checker);
30
30
  if (disabled)
31
31
  return;
32
32
  const streamingValue = getPropertyValue(obj, 'streaming');
@@ -23,7 +23,7 @@ export const addQueueWorker = (logger, node, checker, state) => {
23
23
  if (ts.isObjectLiteralExpression(firstArg)) {
24
24
  const obj = firstArg;
25
25
  const name = getPropertyValue(obj, 'name');
26
- const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Queue worker', name, logger);
26
+ const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Queue worker', name, logger, checker);
27
27
  if (disabled)
28
28
  return;
29
29
  // --- find the referenced function ---
@@ -24,7 +24,7 @@ export const addSchedule = (logger, node, checker, state, options) => {
24
24
  const obj = firstArg;
25
25
  const nameValue = getPropertyValue(obj, 'name');
26
26
  const scheduleValue = getPropertyValue(obj, 'schedule');
27
- const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Scheduler', nameValue, logger);
27
+ const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Scheduler', nameValue, logger, checker);
28
28
  if (disabled)
29
29
  return;
30
30
  const funcInitializer = getPropertyAssignmentInitializer(obj, 'func', true, checker);
@@ -29,7 +29,7 @@ const addWireTrigger = (logger, node, checker, state, firstArg) => {
29
29
  }
30
30
  const obj = firstArg;
31
31
  const nameValue = getPropertyValue(obj, 'name');
32
- const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Trigger', nameValue, logger);
32
+ const { disabled, tags, summary, description, errors } = getCommonWireMetaData(obj, 'Trigger', nameValue, logger, checker);
33
33
  if (disabled)
34
34
  return;
35
35
  const funcInitializer = getPropertyAssignmentInitializer(obj, 'func', true, checker);
@@ -185,7 +185,7 @@ export const addWorkflow = (logger, node, checker, state) => {
185
185
  let inline;
186
186
  let expose;
187
187
  if (ts.isObjectLiteralExpression(firstArg)) {
188
- const metadata = getCommonWireMetaData(firstArg, 'Workflow', workflowName, logger);
188
+ const metadata = getCommonWireMetaData(firstArg, 'Workflow', workflowName, logger, checker);
189
189
  if (metadata.disabled)
190
190
  return;
191
191
  tags = metadata.tags;
@@ -1,14 +1,19 @@
1
1
  import * as ts from 'typescript';
2
+ export type ClassifiedField = {
3
+ path: string;
4
+ classification: 'private' | 'pii' | 'secret' | string;
5
+ };
2
6
  /**
3
7
  * Recursively walks a resolved TypeScript type looking for `__classification__` brands —
4
- * the structural marker emitted by `Private<T>` and `Secret<T>`.
8
+ * the structural marker emitted by `Private<T>`, `Pii<T>`, and `Secret<T>`.
5
9
  *
6
10
  * `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
7
11
  * system as an intersection whose constituents include a type with a `__classification__`
8
12
  * property. We detect that by checking whether any constituent of an
9
13
  * intersection exposes a property named `__classification__`.
10
14
  *
11
- * Returns the list of dotted field paths where a brand was found
12
- * (e.g. `['email', 'address.phone']`). An empty array means clean.
15
+ * Returns the list of classified fields found, each with its dotted path and
16
+ * classification level (e.g. `[{ path: 'email', classification: 'private' }]`).
17
+ * An empty array means clean.
13
18
  */
14
- export declare function findPiiPaths(checker: ts.TypeChecker, type: ts.Type, path?: string, depth?: number, seen?: Set<ts.Type>): string[];
19
+ export declare function findPiiPaths(checker: ts.TypeChecker, type: ts.Type, path?: string, depth?: number, seen?: Set<ts.Type>): ClassifiedField[];
@@ -1,15 +1,16 @@
1
1
  import * as ts from 'typescript';
2
2
  /**
3
3
  * Recursively walks a resolved TypeScript type looking for `__classification__` brands —
4
- * the structural marker emitted by `Private<T>` and `Secret<T>`.
4
+ * the structural marker emitted by `Private<T>`, `Pii<T>`, and `Secret<T>`.
5
5
  *
6
6
  * `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
7
7
  * system as an intersection whose constituents include a type with a `__classification__`
8
8
  * property. We detect that by checking whether any constituent of an
9
9
  * intersection exposes a property named `__classification__`.
10
10
  *
11
- * Returns the list of dotted field paths where a brand was found
12
- * (e.g. `['email', 'address.phone']`). An empty array means clean.
11
+ * Returns the list of classified fields found, each with its dotted path and
12
+ * classification level (e.g. `[{ path: 'email', classification: 'private' }]`).
13
+ * An empty array means clean.
13
14
  */
14
15
  export function findPiiPaths(checker, type, path = '', depth = 0, seen = new Set()) {
15
16
  if (depth > 8 || seen.has(type))
@@ -17,11 +18,20 @@ export function findPiiPaths(checker, type, path = '', depth = 0, seen = new Set
17
18
  seen.add(type);
18
19
  // ── Is this type itself branded? ─────────────────────────────────────────
19
20
  // Private<T> = T & { readonly __classification__: 'private' } → isIntersection()
20
- // where one constituent has a `__classification__` property.
21
+ // where one constituent has a `__classification__` property whose type is a string literal.
21
22
  if (type.isIntersection()) {
22
- const branded = type.types.some((t) => t.getProperties().some((p) => p.name === '__classification__'));
23
- if (branded) {
24
- return [path || '<return value>'];
23
+ for (const t of type.types) {
24
+ const classificationProp = t
25
+ .getProperties()
26
+ .find((p) => p.name === '__classification__');
27
+ if (classificationProp) {
28
+ const decl = classificationProp.valueDeclaration ??
29
+ classificationProp.declarations?.[0];
30
+ const classification = decl
31
+ ? (checker.getTypeOfSymbolAtLocation(classificationProp, decl)?.value ?? 'private')
32
+ : 'private';
33
+ return [{ path: path || '<return value>', classification }];
34
+ }
25
35
  }
26
36
  }
27
37
  const violations = [];
@@ -7,6 +7,53 @@
7
7
  export function sanitizeTypeName(name) {
8
8
  return name.replace(/[^a-zA-Z0-9_$]/g, '_');
9
9
  }
10
+ const CLASSIFICATION_WRAPPERS = new Set(['Private', 'Pii', 'Secret']);
11
+ function findMatchingAngleBracket(type, startIndex) {
12
+ let depth = 0;
13
+ for (let i = startIndex; i < type.length; i += 1) {
14
+ const char = type[i];
15
+ if (char === '<') {
16
+ depth += 1;
17
+ continue;
18
+ }
19
+ if (char === '>' && (i === 0 || type[i - 1] !== '=')) {
20
+ depth -= 1;
21
+ if (depth === 0) {
22
+ return i;
23
+ }
24
+ }
25
+ }
26
+ return -1;
27
+ }
28
+ function stripClassificationWrappers(type) {
29
+ let output = '';
30
+ let index = 0;
31
+ while (index < type.length) {
32
+ const char = type[index];
33
+ if (!/[A-Za-z_$]/.test(char)) {
34
+ output += char;
35
+ index += 1;
36
+ continue;
37
+ }
38
+ let end = index + 1;
39
+ while (end < type.length && /[A-Za-z0-9_$]/.test(type[end])) {
40
+ end += 1;
41
+ }
42
+ const identifier = type.slice(index, end);
43
+ if (CLASSIFICATION_WRAPPERS.has(identifier) && type[end] === '<') {
44
+ const closingIndex = findMatchingAngleBracket(type, end);
45
+ if (closingIndex !== -1) {
46
+ const inner = type.slice(end + 1, closingIndex);
47
+ output += stripClassificationWrappers(inner);
48
+ index = closingIndex + 1;
49
+ continue;
50
+ }
51
+ }
52
+ output += identifier;
53
+ index = end;
54
+ }
55
+ return output;
56
+ }
10
57
  export function generateCustomTypes(typesMap, requiredTypes) {
11
58
  const typeDeclarations = Array.from(typesMap.customTypes.entries())
12
59
  .sort(([a], [b]) => a.localeCompare(b))
@@ -17,11 +64,13 @@ export function generateCustomTypes(typesMap, requiredTypes) {
17
64
  .map(([originalName, { type, references }]) => {
18
65
  const name = sanitizeTypeName(originalName);
19
66
  references.forEach((refName) => {
20
- if (refName !== '__object' && !refName.startsWith('__object_')) {
67
+ if (refName !== '__object' &&
68
+ !refName.startsWith('__object_') &&
69
+ !CLASSIFICATION_WRAPPERS.has(refName)) {
21
70
  requiredTypes.add(refName);
22
71
  }
23
72
  });
24
- const typeString = type;
73
+ const typeString = stripClassificationWrappers(type);
25
74
  const typeNameRegex = /\b[A-Z][a-zA-Z0-9]*\b/g;
26
75
  const potentialTypes = typeString.match(typeNameRegex) || [];
27
76
  potentialTypes.forEach((typeName) => {
@@ -46,12 +95,14 @@ export function generateCustomTypes(typesMap, requiredTypes) {
46
95
  // Type not found in map (ambient/builtin type)
47
96
  }
48
97
  });
49
- if (name === type)
98
+ if (name === typeString)
50
99
  return null;
51
- return `export type ${name} = ${type}`;
100
+ return `export type ${name} = ${typeString}`;
52
101
  });
53
102
  const importsByPath = new Map();
54
103
  for (const typeName of requiredTypes) {
104
+ if (CLASSIFICATION_WRAPPERS.has(typeName))
105
+ continue;
55
106
  try {
56
107
  const typeMeta = typesMap.getTypeMeta(typeName);
57
108
  if (typeMeta.path) {
@@ -192,7 +192,7 @@ export function ensureFunctionMetadata(state, pikkuFuncId, fallbackName, funcIni
192
192
  const firstArg = pikkuFuncCall.arguments[0];
193
193
  if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
194
194
  if (!meta.tags) {
195
- const { tags } = getCommonWireMetaData(firstArg, 'Function', fallbackName || pikkuFuncId);
195
+ const { tags } = getCommonWireMetaData(firstArg, 'Function', fallbackName || pikkuFuncId, undefined, checker);
196
196
  if (tags) {
197
197
  meta.tags = tags;
198
198
  }
@@ -8,7 +8,7 @@ export declare function extractStringLiteral(node: ts.Node, checker: ts.TypeChec
8
8
  /**
9
9
  * Check if node is string-like (string literal or template expression)
10
10
  */
11
- export declare function isStringLike(node: ts.Node, _checker: ts.TypeChecker): boolean;
11
+ export declare function isStringLike(node: ts.Node, checker: ts.TypeChecker): boolean;
12
12
  /**
13
13
  * Check if node is function-like (arrow, function expression, or function declaration)
14
14
  */
@@ -24,6 +24,11 @@ export function extractStringLiteral(node, checker) {
24
24
  if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
25
25
  return extractStringLiteral(node.expression, checker);
26
26
  }
27
+ if (ts.isBinaryExpression(node) &&
28
+ node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
29
+ return (extractStringLiteral(node.left, checker) +
30
+ extractStringLiteral(node.right, checker));
31
+ }
27
32
  // Try to evaluate constant identifiers
28
33
  if (ts.isIdentifier(node)) {
29
34
  const symbol = checker.getSymbolAtLocation(node);
@@ -40,7 +45,7 @@ export function extractStringLiteral(node, checker) {
40
45
  /**
41
46
  * Check if node is string-like (string literal or template expression)
42
47
  */
43
- export function isStringLike(node, _checker) {
48
+ export function isStringLike(node, checker) {
44
49
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
45
50
  return true;
46
51
  }
@@ -48,6 +53,10 @@ export function isStringLike(node, _checker) {
48
53
  if (ts.isTemplateExpression(node)) {
49
54
  return true;
50
55
  }
56
+ // Unwrap type assertions: `expr as Type` or `<Type>expr`
57
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
58
+ return isStringLike(node.expression, checker);
59
+ }
51
60
  return false;
52
61
  }
53
62
  /**
@@ -25,7 +25,7 @@ export declare const getPropertyValue: (obj: ts.ObjectLiteralExpression, propert
25
25
  */
26
26
  export declare const getCommonWireMetaData: (obj: ts.ObjectLiteralExpression, wiringType: string, wiringName: string | null, logger?: {
27
27
  critical: (code: ErrorCode, message: string) => void;
28
- }) => {
28
+ }, checker?: ts.TypeChecker) => {
29
29
  disabled?: true;
30
30
  title?: string;
31
31
  tags?: string[];
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript';
2
2
  import { ErrorCode } from '../error-codes.js';
3
+ import { extractStringLiteral } from './extract-node-value.js';
3
4
  /**
4
5
  * Extracts an array of strings from an object property.
5
6
  */
@@ -94,7 +95,7 @@ export const getPropertyValue = (obj, propertyName) => {
94
95
  * @param logger - Optional logger instance; if not provided, uses console.error
95
96
  * @returns Object containing the common wire metadata fields
96
97
  */
97
- export const getCommonWireMetaData = (obj, wiringType, wiringName, logger) => {
98
+ export const getCommonWireMetaData = (obj, wiringType, wiringName, logger, checker) => {
98
99
  const metadata = {};
99
100
  assertStringLiteralProperty(obj, 'name', wiringType, logger);
100
101
  obj.properties.forEach((prop) => {
@@ -104,16 +105,41 @@ export const getCommonWireMetaData = (obj, wiringType, wiringName, logger) => {
104
105
  prop.initializer.kind === ts.SyntaxKind.TrueKeyword) {
105
106
  metadata.disabled = true;
106
107
  }
107
- else if (propName === 'title' && ts.isStringLiteral(prop.initializer)) {
108
- metadata.title = prop.initializer.text;
108
+ else if (propName === 'title') {
109
+ try {
110
+ metadata.title = checker
111
+ ? extractStringLiteral(prop.initializer, checker)
112
+ : ts.isStringLiteral(prop.initializer)
113
+ ? prop.initializer.text
114
+ : undefined;
115
+ }
116
+ catch {
117
+ // non-static title — skip
118
+ }
109
119
  }
110
- else if (propName === 'summary' &&
111
- ts.isStringLiteral(prop.initializer)) {
112
- metadata.summary = prop.initializer.text;
120
+ else if (propName === 'summary') {
121
+ try {
122
+ metadata.summary = checker
123
+ ? extractStringLiteral(prop.initializer, checker)
124
+ : ts.isStringLiteral(prop.initializer)
125
+ ? prop.initializer.text
126
+ : undefined;
127
+ }
128
+ catch {
129
+ // non-static summary — skip
130
+ }
113
131
  }
114
- else if (propName === 'description' &&
115
- ts.isStringLiteral(prop.initializer)) {
116
- metadata.description = prop.initializer.text;
132
+ else if (propName === 'description') {
133
+ try {
134
+ metadata.description = checker
135
+ ? extractStringLiteral(prop.initializer, checker)
136
+ : ts.isStringLiteral(prop.initializer)
137
+ ? prop.initializer.text
138
+ : undefined;
139
+ }
140
+ catch {
141
+ // non-static description — skip
142
+ }
117
143
  }
118
144
  else if (propName === 'tags') {
119
145
  if (ts.isArrayLiteralExpression(prop.initializer)) {