@pikku/inspector 0.12.85 → 0.12.87

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,84 @@
1
+ ## 0.12.87
2
+
3
+ ### Patch Changes
4
+
5
+ - a95179a: An AI agent's `providerOptions` now reaches the generated metadata.
6
+
7
+ The inspector read `model`, `temperature`, `maxSteps` and `toolChoice` off an agent declaration but never `providerOptions`, so `agentsMeta` always showed an agent with no provider configuration — even though the type says it carries one. Runs were unaffected, since the runner takes the value from the live agent object, which is exactly why the gap went unnoticed by everything except what reads the compiled meta: the console's agent view and `infra.json`.
8
+
9
+ The value is read as an object literal of strings, numbers, booleans, arrays and nested objects, all-or-nothing. A computed one is reported as `PKU156` rather than silently dropped.
10
+
11
+ - 145b32b: Generated type aliases are deterministic
12
+
13
+ A type name that collided across files got a `Math.random()` suffix, so every
14
+ `pikku all` emitted different names into `pikku-agent-map.gen.d.ts` and
15
+ `pikku-workflow-map.gen.d.ts`. Those files sit in the schema generator's
16
+ dependency closure, so rewriting them invalidated the schema cache partway
17
+ through the same run and forced a full `ts-json-schema-generator` pass each
18
+ time. The suffix now comes from the declaring file's path.
19
+
20
+ - 42b7ac3: The app decides which of an addon's functions `rpc.exposed` reaches
21
+
22
+ `wireAddon` takes `expose?: boolean | string[]`, mirroring `mcp`. Unset or
23
+ `true` keeps the functions the addon declared `expose: true`; `false` exposes
24
+ none of the instance's functions; a list names exactly the functions to expose,
25
+ whether or not the addon declared them, typed against the addon's function
26
+ names. A listed name the addon does not publish fails the build with PKU343, a value that is not written inline fails it with
27
+ PKU344,
28
+ and the deploy analyzer's per-addon unit carries only what the wiring exposes.
29
+
30
+ - Updated dependencies [4a9dcd2]
31
+ - Updated dependencies [5ab24ad]
32
+ - Updated dependencies [42b7ac3]
33
+ - @pikku/core@0.12.120
34
+
35
+ ## 0.12.86
36
+
37
+ ### Patch Changes
38
+
39
+ - f05fb4e: A scenario that belongs to no feature is now reported.
40
+
41
+ Features are what the guide is written against: prose cites a feature, and the compiler fills that citation with the evidence from the scenarios the feature owns. A scenario nobody listed is therefore invisible — it runs, it captures screenshots and recordings, and none of it ever reaches a page. That failure is silent today, and it is the single reason two real projects have hundreds of captures bound to nothing.
42
+
43
+ The inspector now walks every feature's scenario list, and any scenario not claimed by one is reported as `PKU682`. It is an error rather than a critical, so `pikku all` keeps passing by default and the list is there to work through; `--fail-on-error` makes it a wall once a project has caught up.
44
+
45
+ Membership reads the scenario each entry names, not the entry itself. A `data` the AST cannot evaluate — `'a'.repeat(64)`, a call, anything computed — leaves the entry partial for the console but says nothing about who owns the scenario, and the check no longer treats the two as the same thing.
46
+
47
+ An entry that names no scenario at all — a spread, a `.map()` — does leave membership unknown. That only matters once something is already unowned, so the check reports it then, as a warn naming both the unowned scenarios and the features that could be hiding them, rather than accusing a scenario that may well be in one of those arrays.
48
+
49
+ - b4a895e: `pikku scenario guide` writes the user guide a scenario suite already contains. A feature reads as a page and a scenario as a section, and a run leaves screenshots behind with the captions their author took them under — the command joins that to the editorial prose a project checks in under `docs/` and writes markdown. It renders no HTML, ships no components, resolves no asset URLs and calls no model: an image is an ordinary relative `![caption](path)`, and whoever consumes the markdown rewrites the paths.
50
+
51
+ A page cites a feature by leaving the marker pair where the block belongs:
52
+
53
+ ```markdown
54
+ ---
55
+ title: Deployments
56
+ ---
57
+
58
+ A deployment is one tracked shipment of your app.
59
+
60
+ <!-- pikku:guide feature=deploymentsFeature -->
61
+ <!-- /pikku:guide -->
62
+
63
+ ## Does my app go down during a deploy?
64
+ ```
65
+
66
+ That one line does both halves of the job. It says _where_ the block goes, which a frontmatter list cannot express, and it is what the coverage gate counts to decide _whether_ a feature is documented at all. The mapping stays many-to-many and falls out of the union of every marker in the tree. A rebuild rewrites exactly the regions between the markers, so every sentence a human wrote around them survives.
67
+
68
+ **The steps are evidence, not content.** A generated block is the scenario's title, the description its author wrote, and the shots it filed — never a numbered Given/When/Then ladder, which is a test report and not something anybody arrives at a documentation page wanting. The sentences are still what the guide is kept honest against: `docs/.guide.lock` records a hash of each feature's step sentences and artifact ids, deliberately not of the image bytes. Restyling a UI changes every screenshot and no sentence; inserting or renaming a step changes what the prose was describing, and the page is reported stale. The lock is generated and checked in, so no hash is ever typed or merged by hand — and a tree whose lock is untracked reports every page as current forever.
69
+
70
+ Every registered feature has to be cited by some page, and a feature that is pure plumbing says so rather than being written about — `pikkuFeature({ document: false })`, threaded through the inspector and `FeatureMeta`. An uncited feature fails the command by name; `--allow-undocumented` downgrades that one failure to a report. A page citing a feature id that is not registered stays an error either way.
71
+
72
+ A guide is only written out of a run that can stand behind it. A run that failed or was killed halfway is refused, because a page is a claim that the product does what it says. So is a narrowed one: `pikku scenario run --flows`/`--features`/`--tags` leaves out scenarios the suite has, and a guide built from it would describe those flows as though they do not exist. `ScenarioRunRecord.selection` records the filters a run was selected with, since nothing in the results afterwards can tell a suite of forty from forty that were asked for.
73
+
74
+ Results are joined to features by `featureId`, falling back to the display name only for records written before that field existed — a title is rewritten freely and two features may share one.
75
+
76
+ Emission is deterministic — identical inputs give byte-identical output, and no timestamp goes in that did not come from the run record. Frontmatter the compiler does not own (`slug`, `draft`, `sidebar_position`, anything else a docs site reads) passes through untouched, and a source written with CRLF line endings is read as having frontmatter.
77
+
78
+ - Updated dependencies [b4a895e]
79
+ - Updated dependencies [b4a895e]
80
+ - @pikku/core@0.12.118
81
+
1
82
  ## 0.12.85
2
83
 
3
84
  ### Patch Changes
@@ -1,4 +1,14 @@
1
1
  import * as ts from 'typescript';
2
2
  import type { AddWiring, InspectorLogger } from '../types.js';
3
+ /**
4
+ * Reads `providerOptions` off the declaration so it survives into `agentsMeta`.
5
+ *
6
+ * The runner takes the value from the live agent object, so an in-process run
7
+ * honours it either way — but everything that reads the compiled meta instead
8
+ * (the console's agent view, `infra.json`, the deploy analyzer) saw an agent
9
+ * with no provider configuration at all. A computed value is reported rather
10
+ * than dropped, because that is the case nobody notices.
11
+ */
12
+ export declare function resolveProviderOptions(obj: ts.ObjectLiteralExpression, agentName: string, logger: InspectorLogger): Record<string, Record<string, unknown>> | null;
3
13
  export declare function resolveWorkflowReferences(obj: ts.ObjectLiteralExpression, checker: ts.TypeChecker, agentName: string, logger: InspectorLogger): string[] | null;
4
14
  export declare const addAgent: AddWiring;
@@ -4,8 +4,33 @@ import { extractWireNames } from '../utils/post-process.js';
4
4
  import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-function-name.js';
5
5
  import { resolveMiddleware, resolveChannelMiddleware, resolveAgentMiddleware, } from '../utils/middleware.js';
6
6
  import { resolvePermissions } from '../utils/permissions.js';
7
+ import { getProperty, jsonObjectProperty } from '../utils/literal-properties.js';
7
8
  import { ErrorCode } from '../error-codes.js';
8
9
  import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js';
10
+ /**
11
+ * Reads `providerOptions` off the declaration so it survives into `agentsMeta`.
12
+ *
13
+ * The runner takes the value from the live agent object, so an in-process run
14
+ * honours it either way — but everything that reads the compiled meta instead
15
+ * (the console's agent view, `infra.json`, the deploy analyzer) saw an agent
16
+ * with no provider configuration at all. A computed value is reported rather
17
+ * than dropped, because that is the case nobody notices.
18
+ */
19
+ export function resolveProviderOptions(obj, agentName, logger) {
20
+ if (!getProperty(obj, 'providerOptions')) {
21
+ return null;
22
+ }
23
+ const providerOptions = jsonObjectProperty(obj, 'providerOptions');
24
+ if (!providerOptions) {
25
+ logger.diagnostic({
26
+ severity: 'warn',
27
+ code: ErrorCode.AGENT_PROVIDER_OPTIONS_UNREADABLE,
28
+ message: `AI agent '${agentName}' has a 'providerOptions' the inspector cannot read statically, so it is missing from the generated metadata. Inline it as an object literal of strings, numbers, booleans, arrays and nested objects.`,
29
+ });
30
+ return null;
31
+ }
32
+ return providerOptions;
33
+ }
9
34
  function resolveToolReferences(obj, checker, agentName, logger) {
10
35
  const property = obj.properties.find((p) => ts.isPropertyAssignment(p) &&
11
36
  ts.isIdentifier(p.name) &&
@@ -267,6 +292,7 @@ export const addAgent = (logger, node, checker, state, options) => {
267
292
  const maxStepsValue = getPropertyValue(obj, 'maxSteps');
268
293
  const temperatureValue = getPropertyValue(obj, 'temperature');
269
294
  const toolChoiceValue = getPropertyValue(obj, 'toolChoice');
295
+ const providerOptionsValue = resolveProviderOptions(obj, nameValue || '', logger);
270
296
  const scorersValue = resolveScorerNames(obj, nameValue || '', logger);
271
297
  const toolsValue = resolveToolReferences(obj, checker, nameValue || '', logger);
272
298
  if (toolsValue) {
@@ -410,6 +436,9 @@ export const addAgent = (logger, node, checker, state, options) => {
410
436
  ...(toolChoiceValue !== null && {
411
437
  toolChoice: toolChoiceValue,
412
438
  }),
439
+ ...(providerOptionsValue !== null && {
440
+ providerOptions: providerOptionsValue,
441
+ }),
413
442
  ...(toolsValue !== null && { tools: toolsValue }),
414
443
  ...(agentsValue !== null && { agents: agentsValue }),
415
444
  ...(workflowsValue !== null && { workflows: workflowsValue }),
@@ -96,6 +96,16 @@ const stringProperty = (config, name) => {
96
96
  const value = getProperty(config, name);
97
97
  return value && ts.isStringLiteralLike(value) ? value.text : undefined;
98
98
  };
99
+ const booleanProperty = (config, name) => {
100
+ const value = getProperty(config, name);
101
+ if (!value) {
102
+ return undefined;
103
+ }
104
+ if (value.kind === ts.SyntaxKind.TrueKeyword) {
105
+ return true;
106
+ }
107
+ return value.kind === ts.SyntaxKind.FalseKeyword ? false : undefined;
108
+ };
99
109
  const stringArrayProperty = (config, name) => {
100
110
  const value = getProperty(config, name);
101
111
  if (!value || !ts.isArrayLiteralExpression(value)) {
@@ -106,6 +116,16 @@ const stringArrayProperty = (config, name) => {
106
116
  .map((e) => e.text);
107
117
  return values.length === value.elements.length ? values : undefined;
108
118
  };
119
+ const mentionedScenario = (element, checker) => {
120
+ if (ts.isIdentifier(element)) {
121
+ return scenarioName(element, checker);
122
+ }
123
+ if (ts.isObjectLiteralExpression(element)) {
124
+ const reference = getProperty(element, 'scenario');
125
+ return reference ? scenarioName(reference, checker) : undefined;
126
+ }
127
+ return undefined;
128
+ };
109
129
  const readEntry = (element, checker) => {
110
130
  if (ts.isIdentifier(element)) {
111
131
  const scenario = scenarioName(element, checker);
@@ -164,6 +184,8 @@ export const addFeature = (logger, node, checker, state) => {
164
184
  exportedName,
165
185
  entries: [],
166
186
  unresolvedEntries: 0,
187
+ mentions: [],
188
+ unnamedEntries: 0,
167
189
  hasBefore: false,
168
190
  hasAfter: false,
169
191
  };
@@ -172,6 +194,10 @@ export const addFeature = (logger, node, checker, state) => {
172
194
  const name = stringProperty(config, 'name');
173
195
  const description = stringProperty(config, 'description');
174
196
  const tags = stringArrayProperty(config, 'tags');
197
+ const document = booleanProperty(config, 'document');
198
+ if (document !== undefined) {
199
+ feature.document = document;
200
+ }
175
201
  if (name !== undefined) {
176
202
  feature.name = name;
177
203
  }
@@ -186,6 +212,13 @@ export const addFeature = (logger, node, checker, state) => {
186
212
  const scenarios = getProperty(config, 'scenarios');
187
213
  if (scenarios && ts.isArrayLiteralExpression(scenarios)) {
188
214
  for (const element of scenarios.elements) {
215
+ const mention = mentionedScenario(element, checker);
216
+ if (mention) {
217
+ feature.mentions.push(mention);
218
+ }
219
+ else {
220
+ feature.unnamedEntries += 1;
221
+ }
189
222
  const entry = readEntry(element, checker);
190
223
  if (entry) {
191
224
  feature.entries.push(entry);
@@ -1,4 +1,5 @@
1
1
  import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
2
3
  function parseStringArray(node) {
3
4
  if (!ts.isArrayLiteralExpression(node))
4
5
  return undefined;
@@ -95,6 +96,7 @@ export function addWireAddon(node, state, logger) {
95
96
  let pkg;
96
97
  let rpcEndpoint;
97
98
  let mcp;
99
+ let expose;
98
100
  let mcpEndpoint;
99
101
  let auth;
100
102
  let tags;
@@ -126,6 +128,18 @@ export function addWireAddon(node, state, logger) {
126
128
  ? prop.initializer.kind === ts.SyntaxKind.TrueKeyword
127
129
  : parseStringArray(prop.initializer);
128
130
  }
131
+ else if (key === 'expose') {
132
+ expose =
133
+ prop.initializer.kind === ts.SyntaxKind.TrueKeyword ||
134
+ prop.initializer.kind === ts.SyntaxKind.FalseKeyword
135
+ ? prop.initializer.kind === ts.SyntaxKind.TrueKeyword
136
+ : parseStringArray(prop.initializer);
137
+ // Read as unset, the deploy analyzer would build this addon's unit from
138
+ // the addon's own declarations while the runtime follows the real list.
139
+ if (expose === undefined) {
140
+ logger.critical(ErrorCode.ADDON_EXPOSE_NOT_STATIC, `wireAddon's expose must be true, false or an array of string literals, got: ${prop.initializer.getText()}`);
141
+ }
142
+ }
129
143
  else if (key === 'mcpEndpoint') {
130
144
  if (ts.isStringLiteral(prop.initializer)) {
131
145
  mcpEndpoint = prop.initializer.text;
@@ -186,6 +200,7 @@ export function addWireAddon(node, state, logger) {
186
200
  rpcEndpoint,
187
201
  mcp,
188
202
  mcpEndpoint,
203
+ expose,
189
204
  auth,
190
205
  tags,
191
206
  scopes,
@@ -25,6 +25,7 @@ export declare enum ErrorCode {
25
25
  SCENARIO_NOT_EXTRACTABLE = "PKU679",
26
26
  SCENARIO_HAS_NO_ASSERTION = "PKU680",
27
27
  SCENARIO_STEP_PROSE_NAMES_ACTOR = "PKU681",
28
+ SCENARIO_HAS_NO_FEATURE = "PKU682",
28
29
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = "PKU600",
29
30
  INVALID_DSL_WORKFLOW = "PKU641",
30
31
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = "PKU642",
@@ -33,6 +34,8 @@ export declare enum ErrorCode {
33
34
  REMOTE_ADDON_AUTH_UNRESOLVED = "PKU339",
34
35
  ADDON_NOT_INSTALLED = "PKU340",
35
36
  ADDON_MCP_FUNCTION_NOT_FOUND = "PKU341",
37
+ ADDON_EXPOSE_FUNCTION_NOT_FOUND = "PKU343",
38
+ ADDON_EXPOSE_NOT_STATIC = "PKU344",
36
39
  DB_COLUMN_NAME_TYPE_CONTRADICTION = "PKU480",
37
40
  DB_JSON_COLUMN_UNTYPED = "PKU481",
38
41
  DB_FORMAT_ON_NON_STRING = "PKU482",
@@ -66,6 +69,7 @@ export declare enum ErrorCode {
66
69
  AGENT_TOOL_NOT_FOUND = "PKU153",
67
70
  AGENT_TOOL_MISSING_DESCRIPTION = "PKU154",
68
71
  AGENT_SCORER_NOT_FOUND = "PKU155",
72
+ AGENT_PROVIDER_OPTIONS_UNREADABLE = "PKU156",
69
73
  SCHEMA_AND_WIRING_COLOCATED = "PKU490",
70
74
  SCHEMA_TRANSFORM_NOT_SUPPORTED = "PKU491",
71
75
  SERVICES_NOT_DESTRUCTURED = "PKU410",
@@ -27,6 +27,7 @@ export var ErrorCode;
27
27
  ErrorCode["SCENARIO_NOT_EXTRACTABLE"] = "PKU679";
28
28
  ErrorCode["SCENARIO_HAS_NO_ASSERTION"] = "PKU680";
29
29
  ErrorCode["SCENARIO_STEP_PROSE_NAMES_ACTOR"] = "PKU681";
30
+ ErrorCode["SCENARIO_HAS_NO_FEATURE"] = "PKU682";
30
31
  ErrorCode["WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED"] = "PKU600";
31
32
  ErrorCode["INVALID_DSL_WORKFLOW"] = "PKU641";
32
33
  ErrorCode["WORKFLOW_GRAPH_ADDON_NOT_WIRED"] = "PKU642";
@@ -36,6 +37,8 @@ export var ErrorCode;
36
37
  ErrorCode["REMOTE_ADDON_AUTH_UNRESOLVED"] = "PKU339";
37
38
  ErrorCode["ADDON_NOT_INSTALLED"] = "PKU340";
38
39
  ErrorCode["ADDON_MCP_FUNCTION_NOT_FOUND"] = "PKU341";
40
+ ErrorCode["ADDON_EXPOSE_FUNCTION_NOT_FOUND"] = "PKU343";
41
+ ErrorCode["ADDON_EXPOSE_NOT_STATIC"] = "PKU344";
39
42
  // Database schema codegen warnings
40
43
  ErrorCode["DB_COLUMN_NAME_TYPE_CONTRADICTION"] = "PKU480";
41
44
  ErrorCode["DB_JSON_COLUMN_UNTYPED"] = "PKU481";
@@ -84,6 +87,8 @@ export var ErrorCode;
84
87
  ErrorCode["AGENT_TOOL_MISSING_DESCRIPTION"] = "PKU154";
85
88
  // Agent scorer reference errors
86
89
  ErrorCode["AGENT_SCORER_NOT_FOUND"] = "PKU155";
90
+ // Agent provider configuration errors
91
+ ErrorCode["AGENT_PROVIDER_OPTIONS_UNREADABLE"] = "PKU156";
87
92
  // File structure errors
88
93
  ErrorCode["SCHEMA_AND_WIRING_COLOCATED"] = "PKU490";
89
94
  // Schema authoring errors
package/dist/inspector.js CHANGED
@@ -7,7 +7,7 @@ import { getFilesAndMethods } from './utils/get-files-and-methods.js';
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js';
8
8
  import { createSourceFileCacheHost } from './source-file-cache.js';
9
9
  import { createNestedProjectFilter } from './utils/nested-project-filter.js';
10
- import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, registerDerivedOAuth2AppSecrets, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateWorkflowGraphAddons, } from './utils/post-process.js';
10
+ import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, registerDerivedOAuth2AppSecrets, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateScenarioFeatures, validateWorkflowGraphAddons, } from './utils/post-process.js';
11
11
  import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js';
12
12
  import { validateTagsResolveToMiddleware } from './utils/validate-tags-resolve-to-middleware.js';
13
13
  import { annotateHttpRouteAuth } from './utils/annotate-http-route-auth.js';
@@ -405,6 +405,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
405
405
  validateWorkflowGraphAddons(logger, state);
406
406
  validateScenarioServices(logger, state);
407
407
  validateScenarioSteps(logger, state);
408
+ validateScenarioFeatures(logger, state);
408
409
  // Needs every route, every function and every wireAddon declaration
409
410
  // together, so it can only run once the whole program is inspected.
410
411
  annotateHttpRouteAuth(state);
@@ -6,6 +6,13 @@ export declare class TypesMap {
6
6
  }>;
7
7
  addCustomType(name: string, type: string, references: string[]): void;
8
8
  addType(originalName: string, path: string): void;
9
+ /**
10
+ * A pure function of the path, with no dependence on insertion order: the
11
+ * alias lands in generated `.d.ts` maps, and anything that varies between
12
+ * runs rewrites those files, which invalidates the schema cache mid-`pikku
13
+ * all` and costs a full ts-json-schema-generator pass each time. A counter
14
+ * over already-taken names would reintroduce that through traversal order.
15
+ */
9
16
  addUniqueType(originalName: string, path: string): string;
10
17
  getUniqueName(name: string): string;
11
18
  getTypeMeta(name: string): {
package/dist/types-map.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  export class TypesMap {
2
3
  map = new Map();
3
4
  customTypes = new Map();
@@ -7,8 +8,16 @@ export class TypesMap {
7
8
  addType(originalName, path) {
8
9
  this.map.set(originalName, { originalName, path });
9
10
  }
11
+ /**
12
+ * A pure function of the path, with no dependence on insertion order: the
13
+ * alias lands in generated `.d.ts` maps, and anything that varies between
14
+ * runs rewrites those files, which invalidates the schema cache mid-`pikku
15
+ * all` and costs a full ts-json-schema-generator pass each time. A counter
16
+ * over already-taken names would reintroduce that through traversal order.
17
+ */
10
18
  addUniqueType(originalName, path) {
11
- const uniqueName = `${originalName}_${Math.random().toString(36).substring(7)}`;
19
+ const digest = createHash('sha256').update(path).digest('hex').slice(0, 12);
20
+ const uniqueName = `${originalName}_${digest}`;
12
21
  this.map.set(uniqueName, { originalName, path });
13
22
  return uniqueName;
14
23
  }
package/dist/types.d.ts CHANGED
@@ -455,6 +455,8 @@ export interface InspectorFeature {
455
455
  name?: string;
456
456
  description?: string;
457
457
  tags?: string[];
458
+ /** `document: false` on the config, and only that — an absent flag documents. */
459
+ document?: boolean;
458
460
  entries: InspectorFeatureEntry[];
459
461
  /**
460
462
  * Entries that are not literal — a spread, a `.map()`, a call. Counted rather
@@ -462,6 +464,17 @@ export interface InspectorFeature {
462
464
  * quietly showing a short feature.
463
465
  */
464
466
  unresolvedEntries: number;
467
+ /**
468
+ * Every scenario the array names, including entries whose `data` could not be
469
+ * read. Membership is knowable there even when the entry is not — which is
470
+ * what the check for a scenario belonging to no feature compares against.
471
+ */
472
+ mentions: string[];
473
+ /**
474
+ * Entries that name no scenario at all — a spread, a `.map()`. Only these
475
+ * leave membership genuinely unknown.
476
+ */
477
+ unnamedEntries: number;
465
478
  /**
466
479
  * Feature hooks run once around the whole group, never per scenario, and are
467
480
  * runtime-only — so only their presence is recorded.
@@ -596,6 +609,13 @@ export interface InspectorState {
596
609
  * endpoint, which is where they have always been.
597
610
  */
598
611
  mcpEndpoint?: boolean | string;
612
+ /**
613
+ * Which functions `rpc.exposed` may call under `<name>:<function>`.
614
+ * Unset or `true` keeps what the addon declared `expose: true`; `false`
615
+ * exposes none; a list names exactly the functions to expose, whatever
616
+ * the addon declared.
617
+ */
618
+ expose?: boolean | string[];
599
619
  /**
600
620
  * The addon's own gates. `runPikkuFunc` applies these to every function
601
621
  * in the package, on every wiring path — not just `namespace:function`.
@@ -20,3 +20,14 @@ export declare const booleanProperty: (config: ts.ObjectLiteralExpression, name:
20
20
  export declare const stringArrayProperty: (config: ts.ObjectLiteralExpression, name: string) => string[] | undefined;
21
21
  /** Unwraps `x as const` / `x satisfies T` so a cast is still extracted. */
22
22
  export declare const unwrapAs: (node: ts.Expression) => ts.Expression;
23
+ /**
24
+ * Reads a property whose value is a JSON-shaped object literal — nested
25
+ * objects, arrays, strings, numbers, booleans and `null`.
26
+ *
27
+ * All-or-nothing for the same reason as `stringArrayProperty`, and then some:
28
+ * these values are handed to a provider verbatim, so a half-read
29
+ * `providerOptions` would send the model a *different* configuration from the
30
+ * one in source rather than none. `undefined` for anything computed lets the
31
+ * caller say so instead of guessing.
32
+ */
33
+ export declare const jsonObjectProperty: (config: ts.ObjectLiteralExpression, name: string) => Record<string, unknown> | undefined;
@@ -67,3 +67,82 @@ export const stringArrayProperty = (config, name) => {
67
67
  export const unwrapAs = (node) => ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
68
68
  ? unwrapAs(node.expression)
69
69
  : node;
70
+ /**
71
+ * Reads a property whose value is a JSON-shaped object literal — nested
72
+ * objects, arrays, strings, numbers, booleans and `null`.
73
+ *
74
+ * All-or-nothing for the same reason as `stringArrayProperty`, and then some:
75
+ * these values are handed to a provider verbatim, so a half-read
76
+ * `providerOptions` would send the model a *different* configuration from the
77
+ * one in source rather than none. `undefined` for anything computed lets the
78
+ * caller say so instead of guessing.
79
+ */
80
+ export const jsonObjectProperty = (config, name) => {
81
+ const value = getProperty(config, name);
82
+ if (!value) {
83
+ return undefined;
84
+ }
85
+ const read = readJsonValue(value);
86
+ return read.ok && isPlainRecord(read.value) ? read.value : undefined;
87
+ };
88
+ const isPlainRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
89
+ const UNREADABLE = { ok: false };
90
+ const readJsonValue = (node) => {
91
+ const value = unwrapAs(node);
92
+ if (ts.isStringLiteralLike(value)) {
93
+ return { ok: true, value: value.text };
94
+ }
95
+ if (ts.isNumericLiteral(value)) {
96
+ return { ok: true, value: Number(value.text) };
97
+ }
98
+ if (ts.isPrefixUnaryExpression(value) &&
99
+ value.operator === ts.SyntaxKind.MinusToken &&
100
+ ts.isNumericLiteral(value.operand)) {
101
+ return { ok: true, value: -Number(value.operand.text) };
102
+ }
103
+ if (value.kind === ts.SyntaxKind.TrueKeyword) {
104
+ return { ok: true, value: true };
105
+ }
106
+ if (value.kind === ts.SyntaxKind.FalseKeyword) {
107
+ return { ok: true, value: false };
108
+ }
109
+ if (value.kind === ts.SyntaxKind.NullKeyword) {
110
+ return { ok: true, value: null };
111
+ }
112
+ if (ts.isArrayLiteralExpression(value)) {
113
+ const entries = [];
114
+ for (const element of value.elements) {
115
+ const read = readJsonValue(element);
116
+ if (!read.ok) {
117
+ return UNREADABLE;
118
+ }
119
+ entries.push(read.value);
120
+ }
121
+ return { ok: true, value: entries };
122
+ }
123
+ if (ts.isObjectLiteralExpression(value)) {
124
+ const record = {};
125
+ for (const property of value.properties) {
126
+ if (!ts.isPropertyAssignment(property)) {
127
+ return UNREADABLE;
128
+ }
129
+ const key = ts.isIdentifier(property.name)
130
+ ? property.name.text
131
+ : ts.isStringLiteralLike(property.name)
132
+ ? property.name.text
133
+ : ts.isNumericLiteral(property.name)
134
+ ? property.name.text
135
+ : null;
136
+ if (key === null) {
137
+ return UNREADABLE;
138
+ }
139
+ const read = readJsonValue(property.initializer);
140
+ if (!read.ok) {
141
+ return UNREADABLE;
142
+ }
143
+ record[key] = read.value;
144
+ }
145
+ return { ok: true, value: record };
146
+ }
147
+ return UNREADABLE;
148
+ };
@@ -188,6 +188,15 @@ export async function loadAddonFunctionsMeta(logger, state) {
188
188
  }
189
189
  }
190
190
  }
191
+ // Same reasoning as mcp: a listed name the addon does not publish is a
192
+ // function the app believes callable and never is.
193
+ if (Array.isArray(decl.expose)) {
194
+ for (const funcName of decl.expose) {
195
+ if (!Object.hasOwn(meta, funcName)) {
196
+ logger.critical(ErrorCode.ADDON_EXPOSE_FUNCTION_NOT_FOUND, `wireAddon('${namespace}') lists '${funcName}' under expose, but ${decl.package} publishes no such function.`);
197
+ }
198
+ }
199
+ }
191
200
  // Load addon secrets meta
192
201
  try {
193
202
  const secretsMetaPath = require.resolve(`${decl.package}/.pikku/secrets/pikku-secrets-meta.gen.json`);
@@ -129,6 +129,31 @@ export declare const proseOpensWithActor: (prose: string, actor: string) => bool
129
129
  * singletons the step actually needs.
130
130
  */
131
131
  export declare function validateScenarioSteps(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
132
+ /**
133
+ * Reports scenarios that no `pikkuFeature` lists.
134
+ *
135
+ * A feature is what a run stamps onto each result, and the only handle anything
136
+ * downstream has on a scenario: the console groups by it and the guide cites it.
137
+ * A scenario in no feature still runs and still passes — it simply files its
138
+ * results, and any screenshot or recording it took, where nothing can reach
139
+ * them. That is not a style violation, it is work being thrown away, and it is
140
+ * invisible precisely because the suite is green.
141
+ *
142
+ * It is an `error` rather than a `critical` because every suite written before
143
+ * the rule has some, and a hard failure on a CLI bump would strand them with no
144
+ * way to defer. `--fail-on-error` is how a project opts in once it has bound
145
+ * what it has.
146
+ *
147
+ * Membership reads the scenario each entry names, not the entry itself: a
148
+ * `data` the AST cannot evaluate leaves the entry partial for the console but
149
+ * says nothing about who owns the scenario.
150
+ *
151
+ * An entry that names no scenario at all (a spread, a `.map()`) does leave
152
+ * membership unknown. That only matters when something is already unowned, so
153
+ * the check reports it then and downgrades to a warn naming both sides, rather
154
+ * than accusing a scenario that may well be in that array.
155
+ */
156
+ export declare function validateScenarioFeatures(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
132
157
  /**
133
158
  * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
134
159
  * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
@@ -943,6 +943,79 @@ export function validateScenarioSteps(logger, state) {
943
943
  }
944
944
  }
945
945
  }
946
+ /**
947
+ * Reports scenarios that no `pikkuFeature` lists.
948
+ *
949
+ * A feature is what a run stamps onto each result, and the only handle anything
950
+ * downstream has on a scenario: the console groups by it and the guide cites it.
951
+ * A scenario in no feature still runs and still passes — it simply files its
952
+ * results, and any screenshot or recording it took, where nothing can reach
953
+ * them. That is not a style violation, it is work being thrown away, and it is
954
+ * invisible precisely because the suite is green.
955
+ *
956
+ * It is an `error` rather than a `critical` because every suite written before
957
+ * the rule has some, and a hard failure on a CLI bump would strand them with no
958
+ * way to defer. `--fail-on-error` is how a project opts in once it has bound
959
+ * what it has.
960
+ *
961
+ * Membership reads the scenario each entry names, not the entry itself: a
962
+ * `data` the AST cannot evaluate leaves the entry partial for the console but
963
+ * says nothing about who owns the scenario.
964
+ *
965
+ * An entry that names no scenario at all (a spread, a `.map()`) does leave
966
+ * membership unknown. That only matters when something is already unowned, so
967
+ * the check reports it then and downgrades to a warn naming both sides, rather
968
+ * than accusing a scenario that may well be in that array.
969
+ */
970
+ export function validateScenarioFeatures(logger, state) {
971
+ const features = state.workflows.featureFiles;
972
+ if (!features) {
973
+ return;
974
+ }
975
+ const owned = new Set();
976
+ for (const feature of features.values()) {
977
+ for (const mention of feature.mentions) {
978
+ owned.add(mention);
979
+ }
980
+ }
981
+ const files = state.workflows.files;
982
+ const unowned = [];
983
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
984
+ if (!meta.scenario) {
985
+ continue;
986
+ }
987
+ const exported = files?.get(workflowName)?.exportedName;
988
+ if (owned.has(workflowName) || (exported && owned.has(exported))) {
989
+ continue;
990
+ }
991
+ unowned.push(workflowName);
992
+ }
993
+ if (unowned.length === 0) {
994
+ return;
995
+ }
996
+ const unreadable = [...features.values()]
997
+ .filter((feature) => feature.unnamedEntries > 0)
998
+ .map((feature) => feature.exportedName);
999
+ if (unreadable.length > 0) {
1000
+ logger.diagnostic({
1001
+ severity: 'warn',
1002
+ code: ErrorCode.SCENARIO_HAS_NO_FEATURE,
1003
+ message: `${unowned.map((name) => `'${name}'`).join(', ')} appear in no feature's \`scenarios\` array, ` +
1004
+ `but ${unreadable.map((name) => `'${name}'`).join(', ')} build theirs with a spread or a call, so one of them may still be listed there. ` +
1005
+ `List those scenarios literally and this becomes a definite answer either way.`,
1006
+ });
1007
+ return;
1008
+ }
1009
+ for (const name of unowned) {
1010
+ logger.diagnostic({
1011
+ severity: 'error',
1012
+ code: ErrorCode.SCENARIO_HAS_NO_FEATURE,
1013
+ message: `Scenario '${name}' is listed by no pikkuFeature, so nothing downstream can reach it — ` +
1014
+ `a run stamps no feature onto its results, the console groups it under none, and a guide cannot cite it. ` +
1015
+ `Add it to a feature's \`scenarios\` array.`,
1016
+ });
1017
+ }
1018
+ }
946
1019
  /**
947
1020
  * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
948
1021
  * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
@@ -16,28 +16,32 @@ export function validateExposedFunctionsGated(logger, state) {
16
16
  for (const [funcName, meta] of Object.entries(state.functions.meta)) {
17
17
  if (!meta.expose)
18
18
  continue;
19
- // A pikkuFunc always requires a session, on every path.
20
- if (meta.sessionless === false)
19
+ if (gatesItself(meta))
21
20
  continue;
22
- // Scenario steps are refused by rpcExposed regardless of `expose`.
23
- if (meta.scenarioStep)
24
- continue;
25
- // The author declared that the body authorizes its own callers — a gate
26
- // this check has no way to see. Taking the claim at face value is the
27
- // point: without it the warning fires forever on functions that are fine,
28
- // and a warning that is usually wrong stops being read.
29
- if (meta.permissionsInBody === true)
30
- continue;
31
- // The function gates itself.
32
- if (meta.auth === true)
21
+ if (isGatedByItsAddon(state, meta.packageName))
33
22
  continue;
34
- if (meta.scopes && meta.scopes.length > 0)
23
+ ungated.push(funcName);
24
+ }
25
+ // A `wireAddon` expose list can open an addon function its author never
26
+ // exposed, so the app that listed it is the one to tell. The namespaced call
27
+ // is governed by that instance's config alone.
28
+ //
29
+ // knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
30
+ for (const [namespace, declaration] of state.rpc?.wireAddonDeclarations ??
31
+ []) {
32
+ if (!Array.isArray(declaration.expose))
35
33
  continue;
36
- if (meta.permissions && meta.permissions.length > 0)
34
+ if (declaration.auth === true)
37
35
  continue;
38
- if (isGatedByItsAddon(state, meta.packageName))
36
+ if (declaration.scopes && declaration.scopes.length > 0)
39
37
  continue;
40
- ungated.push(funcName);
38
+ const addonMeta = state.addonFunctions?.[namespace] ?? {};
39
+ for (const funcName of declaration.expose) {
40
+ const meta = addonMeta[funcName];
41
+ if (!meta || gatesItself(meta))
42
+ continue;
43
+ ungated.push(`${namespace}:${funcName}`);
44
+ }
41
45
  }
42
46
  if (ungated.length === 0)
43
47
  return;
@@ -64,6 +68,19 @@ export function validateExposedFunctionsGated(logger, state) {
64
68
  `${one ? 'it was' : 'they were'} not meant to be callable from outside.`,
65
69
  });
66
70
  }
71
+ const gatesItself = (meta) =>
72
+ // A pikkuFunc always requires a session, on every path.
73
+ meta.sessionless === false ||
74
+ // Scenario steps are refused by rpcExposed regardless of `expose`.
75
+ meta.scenarioStep === true ||
76
+ // The author declared that the body authorizes its own callers — a gate
77
+ // this check has no way to see. Taking the claim at face value is the
78
+ // point: without it the warning fires forever on functions that are fine,
79
+ // and a warning that is usually wrong stops being read.
80
+ meta.permissionsInBody === true ||
81
+ meta.auth === true ||
82
+ (meta.scopes?.length ?? 0) > 0 ||
83
+ (meta.permissions?.length ?? 0) > 0;
67
84
  /**
68
85
  * Whether every function in this package is already gated by its `wireAddon`.
69
86
  * Those gates are applied by `runPikkuFunc` on every wiring path, so a function
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.85",
3
+ "version": "0.12.87",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -55,10 +55,10 @@
55
55
  "zod-to-ts": "^2.1.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "@pikku/core": "^0.12.116"
58
+ "@pikku/core": "^0.12.120"
59
59
  },
60
60
  "devDependencies": {
61
- "@pikku/core": "^0.12.116",
61
+ "@pikku/core": "^0.12.120",
62
62
  "@types/node": "^24.13.3"
63
63
  },
64
64
  "engines": {