@pikku/inspector 0.12.48 → 0.12.50

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,96 @@
1
+ ## 0.12.50
2
+
3
+ ### Patch Changes
4
+
5
+ - b89d3b3: Bring the knowledge base into OSS: a package, a CLI gate, a console browser and a skill
6
+
7
+ `knowledge/` is where a project records the things `pikku meta` cannot tell you —
8
+ what a slice is for, which rule was chosen and what it rules out, what is still an
9
+ open question. Tables, routes, schemas and permissions are generated, so a note
10
+ that repeats them is a copy that will drift, and the profile refuses the sections
11
+ where that happens.
12
+ - **`@pikku/knowledge`** (new) reads the notes, builds the link graph in both
13
+ directions, and validates the app-project profile: every note typed, every
14
+ section indexed, every slice carrying a third-person gherkin scenario and at
15
+ most three entities, and every `resource:` URI resolving against the generated
16
+ meta. The resource check fails closed on drift and open on ignorance — a prefix
17
+ whose meta is absent is skipped rather than called dangling.
18
+ - **`pikku knowledge validate`** and **`pikku knowledge index`** replace the dead
19
+ three-flat-files check. Both exit non-zero on an inconsistent base, so a
20
+ pipeline can stop on one; `index` refreshes each `index.md` listing while
21
+ leaving the prose around it alone, and now gives a section that holds only
22
+ sub-sections an index of its own instead of leaving it unreachable.
23
+ - **The console** gains a read-only Knowledge page: notes grouped by section,
24
+ a rendered document with its tags, resources, links in both directions and the
25
+ findings against it, and intra-bundle markdown links that open the linked note
26
+ instead of leaving the page. Read-only by design — a note is edited in the repo,
27
+ in the same commit as the code it describes.
28
+ - **The `pikku-knowledge` skill** documents the format for agents, and Fabric
29
+ builds on it rather than restating it.
30
+ - **`@pikku/inspector`**: a zod schema imported from a built workspace package
31
+ resolved to that package's `.d.ts`, which has no runtime exports at all, so
32
+ every schema in it was reported missing. The emitted JS beside it is imported
33
+ instead.
34
+
35
+ - b89d3b3: Invalidate the TS-schema cache when a type it was built from changes
36
+
37
+ The on-disk schema cache was keyed on the synthesized custom-types source, the
38
+ generator options and the inspector version — but not on the types it actually
39
+ resolves. A named type (anything a function declares as its input or output)
40
+ appears in that source only as a name; its definition is read out of a project
41
+ file or a dependency's `.d.ts`. Change the shape without changing the name and
42
+ the key is identical, so pikku serves a schema for a type that no longer looks
43
+ like that, and requests are validated against it.
44
+
45
+ The stale schema also outlives `rm -rf .pikku`, because the cache lives in
46
+ `node_modules/.cache/pikku/ts-schemas.json`.
47
+
48
+ Codegen now records the files each schema set was derived from, storing their
49
+ mtime and size beside it, and regenerates when any of them moves or disappears.
50
+ The schema program is rooted at the virtual types file, so its source files are
51
+ exactly the transitive closure the schemas depend on. A cache written by an
52
+ earlier version carries no dep list and is treated as stale. The check costs
53
+ about 3ms per cache hit, and also guards the in-process cache, so `pikku dev`
54
+ picks up a type edit without a restart.
55
+
56
+ - Updated dependencies [384e484]
57
+ - Updated dependencies [b5a73fb]
58
+ - Updated dependencies [6be5ab0]
59
+ - @pikku/core@0.12.72
60
+
61
+ ## 0.12.49
62
+
63
+ ### Patch Changes
64
+
65
+ - 426610a: Scenario instrumentation is no longer scaffolded into projects, and no longer deploys.
66
+
67
+ `scaffold.scenarios` generated four functions — `pikkuScenarioTakeLiveCoverage`, `pikkuScenarioResetLiveCoverage`, `pikkuScenarioResetStubs`, `pikkuScenarioGetStubCalls` — into the project's own source. As project source they were indistinguishable from application code: registered in the app bootstrap, listed in the app's function and RPC meta, and shipped `expose: true` inside every deployed bundle. Coverage and stub inspection are things you do to a development server; production carried two endpoints that fingerprint the build and one that resets a global tracker, gated only by whether a metadata file happened to sit beside the bundle.
68
+
69
+ `pikku dev` now registers the implementations itself, after the app bootstrap. Nothing is generated, nothing is written to the project, and a bundle cannot carry what was never in its bootstrap — `pikku serve` and every deployed unit have no trace of them. The scenario runner reaches them over `/rpc/<name>` exactly as before.
70
+
71
+ Also:
72
+ - The inspector ignores these four names wherever it finds them, so a project that has not regenerated — and still has the scaffolded file checked in — stops deploying it immediately. Codegen deletes the retired scaffold on its next run.
73
+ - They no longer count towards a project's function total, so `pikku scenario --coverage` stops reporting four permanently-uncovered functions that were never the project's to cover.
74
+ - The instrumentation no longer carries schemas (there was nothing to validate but one optional string), which drops the `zod` dependency the scaffold silently required of every project that enabled it.
75
+ - They are registered sessionless, so `scaffold.scenarios: true` — as opposed to `'auth'` — now genuinely means "no session required". As a sessioned `pikkuFunc` with `auth: false`, it demanded a session anyway and logged a warning saying so.
76
+
77
+ - 09973b9: Scenarios, features and steps no longer reach a deployment.
78
+
79
+ Steps were already held back from the app bootstrap, so a deployed server never imported a step body. Everything _about_ a scenario still travelled with the application: a `pikkuScenario(...)` is a function, so its name, schemas and hashes sat in the app function meta; the schemas it and its steps validate against sat in the app's `register.gen.ts` — on one project 458 of the 582 registered schemas belonged to tests; its name sat in the internal RPC meta; and because a scenario is _also_ a workflow, the inspector synthesised a `wf-orchestrator-<scenario>` queue worker for each one. The deploy analyzer, which reads inspector state rather than the partitioned codegen output, then read all of it back as application code: a unit per scenario, a `WorkflowDefinition` per scenario, and a real queue per scenario. A 13-scenario suite turned into 13 production queues named after tests, waiting for a provider to create them.
80
+
81
+ The existing scenario/app partition is now applied everywhere it was missing. `FunctionRuntimeMeta` gains a `scenario` marker (the counterpart of `scenarioStep`) so a scenario body is recognisable without walking the workflow graph; scenario bodies join their steps on the scenario side of the function-meta and registration split; schemas only a scenario or step needs are written and registered under `.pikku/scenarios/schemas/` and imported by the scenario bootstrap alone; scenario names are dropped from the internal RPC meta; no orchestrator queue worker is synthesised for a scenario; and the deploy analyzer drops both scenario functions and scenario workflows before it decides what a deployment contains.
82
+
83
+ The MCP metas are keyed by wiring rather than by function, so a scenario wired as an MCP tool, resource or prompt was the one id that still reached the manifest after the function and workflow filters — as an endpoint on the gateway plus a gateway dependency on a unit that was never emitted. Those ids are now filtered too.
84
+
85
+ `scenarioSchemaDirectory` is rejected when it resolves to the same directory as `schemaDirectory`. A schema write owns its directory — it emits `register.gen.ts` and prunes every schema file its own required-set does not name — so sharing one would replace the application register with the scenario-only one and delete the app's schema files, which nothing downstream can detect.
86
+
87
+ Nothing changes for `pikku scenario run` — the scenario bootstrap still registers every scenario, feature, step, meta and schema. What changes is that a bundle stops carrying them.
88
+
89
+ - Updated dependencies [8a2c993]
90
+ - Updated dependencies [a261006]
91
+ - Updated dependencies [09973b9]
92
+ - @pikku/core@0.12.71
93
+
1
94
  ## 0.12.48
2
95
 
3
96
  ### Patch Changes
package/LICENSE ADDED
@@ -0,0 +1,106 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: Yasser Fadl and Pikku contributors
6
+ Licensed Work: TypeScript inspector for Pikku — discovers functions and wirings to drive code generation (@pikku/inspector)
7
+ Pikku Console (@pikku/console)
8
+ The Licensed Work is (c) 2021 - present Yasser Fadl and Pikku contributors
9
+ Additional Use Grant: You may make production use of the Licensed Work for any purpose,
10
+ including creating free or open source software. However, you may
11
+ not offer as a commercial product or service any software featuring
12
+ a visual or graphical interface for authoring, editing, building,
13
+ or managing Pikku code, Pikku functions, Pikku handlers, or Pikku
14
+ workflows.
15
+ Change Date: Four years from the date the Licensed Work was first made available
16
+ Change License: MIT License
17
+
18
+ For information about alternative licensing arrangements, contact yasser.fadl@gmail.com
19
+
20
+ Notice
21
+
22
+ The Business Source License (this document, or the "License") is not an Open Source
23
+ license. However, the Licensed Work will eventually be made available under an Open
24
+ Source License, as stated in this License.
25
+
26
+ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
27
+ "Business Source License" is a trademark of MariaDB Corporation Ab.
28
+
29
+ -----------------------------------------------------------------------------
30
+
31
+ Business Source License 1.1
32
+
33
+ Terms
34
+
35
+ The Licensor hereby grants you the right to copy, modify, create derivative works,
36
+ redistribute, and make non-production use of the Licensed Work. The Licensor may make
37
+ an Additional Use Grant, above, permitting limited production use.
38
+
39
+ Effective on the Change Date, or the fourth anniversary of the first publicly
40
+ available distribution of a specific version of the Licensed Work under this License,
41
+ whichever comes first, the Licensor hereby grants you rights under the terms of the
42
+ Change License, and the rights granted in the paragraph above terminate.
43
+
44
+ If your use of the Licensed Work does not comply with the requirements currently in
45
+ effect as described in this License, you must purchase a commercial license from the
46
+ Licensor, its affiliated entities, or authorized resellers, or you must refrain from
47
+ using the Licensed Work.
48
+
49
+ All copies of the original and modified Licensed Work, and derivative works of the
50
+ Licensed Work, are subject to this License. This License applies separately for each
51
+ version of the Licensed Work and the Change Date may vary for each version of the
52
+ Licensed Work released by Licensor.
53
+
54
+ You must conspicuously display this License on each original or modified copy of the
55
+ Licensed Work. If you receive the Licensed Work in original or modified form from a
56
+ third party, the terms and conditions set forth in this License apply to your use of
57
+ that work.
58
+
59
+ Any use of the Licensed Work in violation of this License will automatically terminate
60
+ your rights under this License for the current and all other versions of the Licensed
61
+ Work.
62
+
63
+ This License does not grant you any right in any trademark or logo of Licensor or its
64
+ affiliates (provided that you may use a trademark or logo of Licensor as expressly
65
+ required by this License).
66
+
67
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN "AS IS"
68
+ BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED,
69
+ INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
70
+ PURPOSE, NON-INFRINGEMENT, AND TITLE.
71
+
72
+ MariaDB hereby grants you permission to use this License's text to license your works,
73
+ and to refer to it using the trademark "Business Source License", as long as you
74
+ comply with the Covenants of Licensor below.
75
+
76
+ Covenants of Licensor
77
+
78
+ In consideration of the right to use this License's text and the "Business Source
79
+ License" name and trademark, Licensor covenants to MariaDB, and to all other
80
+ recipients of the licensed work to be provided by Licensor:
81
+
82
+ 1. To specify as the Change License the GPL Version 2.0 or any later version, or a
83
+ license that is compatible with GPL Version 2.0 or a later version, where
84
+ "compatible" means that software provided under the Change License can be included
85
+ in a program with software provided under GPL Version 2.0 or a later version.
86
+ Licensor may specify additional Change Licenses without limitation.
87
+
88
+ 2. To either: (a) specify an additional grant of rights to use that does not impose
89
+ any additional restriction on the right granted in this License, as the Additional
90
+ Use Grant; or (b) insert the text "None".
91
+
92
+ 3. To specify a Change Date.
93
+
94
+ 4. Not to modify this License in any other way.
95
+
96
+ -----------------------------------------------------------------------------
97
+
98
+ Note: Every other Pikku package — @pikku/core, the runtimes, the services, the clients,
99
+ and @pikku/skills — is licensed under the MIT License. Each package ships its own LICENSE
100
+ file stating its terms; that file governs, and this one covers only the Licensed Work
101
+ named above.
102
+
103
+ In particular, the Pikku agent skills (@pikku/skills) are MIT. They are the instruction
104
+ set coding agents read to build, wire and deploy Pikku projects, and they are meant to be
105
+ adopted, forked and extended by any harness — so their terms do not depend on the CLI
106
+ that happens to install them.
@@ -11,6 +11,7 @@ import { resolvePermissions } from '../utils/permissions.js';
11
11
  import { extractWireNames } from '../utils/post-process.js';
12
12
  import { ErrorCode } from '../error-codes.js';
13
13
  import { findPiiPaths } from '../utils/check-pii-output.js';
14
+ import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
14
15
  const isValidVariableName = (name) => {
15
16
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
16
17
  return regex.test(name);
@@ -285,6 +286,10 @@ export const addFunctions = (logger, node, checker, state, options) => {
285
286
  // never RPC-registered: a step may drive a browser, so exposing it over the
286
287
  // network would put a test harness on the public API surface.
287
288
  const isScenarioStep = expression.text === 'pikkuScenarioStep';
289
+ // A scenario's own body. Marked so the codegen partitions and the deploy
290
+ // analyzer can tell it apart from an application workflow without having to
291
+ // reach into the workflow graph.
292
+ const isScenario = expression.text === 'pikkuScenario';
288
293
  // only handle calls like pikkuFunc(...)
289
294
  if (!ts.isIdentifier(expression) || !expression.text.startsWith('pikku')) {
290
295
  return;
@@ -295,6 +300,14 @@ export const addFunctions = (logger, node, checker, state, options) => {
295
300
  if (!pikkuFuncId || pikkuFuncId.startsWith('__temp_')) {
296
301
  return;
297
302
  }
303
+ // pikku's own scenario instrumentation, which older projects still carry as a
304
+ // scaffolded file in their source. It is not application code and must not be
305
+ // registered, metadata'd or deployed as if it were — `pikku dev` registers the
306
+ // implementations itself, and that is the only server that should have them.
307
+ if (isScenarioInstrumentationFunction(pikkuFuncId) ||
308
+ isScenarioInstrumentationFunction(name)) {
309
+ return;
310
+ }
298
311
  let title;
299
312
  let tags;
300
313
  let summary;
@@ -784,6 +797,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
784
797
  expose: expose || undefined,
785
798
  remote: remote || undefined,
786
799
  scenarioStep: isScenarioStep || undefined,
800
+ scenario: isScenario || undefined,
787
801
  mcp: mcpEnabled || undefined,
788
802
  readonly: readonly_ || undefined,
789
803
  deploy: deploy || undefined,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The instrumentation RPCs `pikku scenario run` calls on the server it is
3
+ * testing: snapshot/reset live coverage, and reset/read recorded stub calls.
4
+ *
5
+ * They are pikku's own, not the application's. They used to be scaffolded into
6
+ * the project as ordinary `pikkuFunc`s, which made them indistinguishable from
7
+ * application code — so they were registered in the app bootstrap, listed in the
8
+ * app's function and RPC meta, and shipped, `expose: true`, in every deployed
9
+ * bundle. A deployment has no use for them: coverage and stub inspection are
10
+ * things you do to a development server.
11
+ *
12
+ * The inspector therefore ignores these names wherever it finds them, and
13
+ * `pikku dev` registers the implementations itself. Ignoring by name (rather
14
+ * than deleting the scaffold and requiring every project to regenerate) is what
15
+ * keeps an existing project's checked-in `scenarios.gen.ts` from deploying.
16
+ */
17
+ export declare const SCENARIO_INSTRUMENTATION_FUNCTIONS: readonly ["pikkuScenarioTakeLiveCoverage", "pikkuScenarioResetLiveCoverage", "pikkuScenarioResetStubs", "pikkuScenarioGetStubCalls"];
18
+ export type ScenarioInstrumentationFunction = (typeof SCENARIO_INSTRUMENTATION_FUNCTIONS)[number];
19
+ /**
20
+ * True for a scenario instrumentation function, by name. Versioned ids
21
+ * (`name@v2`) are matched on their base so a project that bumps one is still
22
+ * covered.
23
+ */
24
+ export declare const isScenarioInstrumentationFunction: (name: string | undefined) => boolean;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The instrumentation RPCs `pikku scenario run` calls on the server it is
3
+ * testing: snapshot/reset live coverage, and reset/read recorded stub calls.
4
+ *
5
+ * They are pikku's own, not the application's. They used to be scaffolded into
6
+ * the project as ordinary `pikkuFunc`s, which made them indistinguishable from
7
+ * application code — so they were registered in the app bootstrap, listed in the
8
+ * app's function and RPC meta, and shipped, `expose: true`, in every deployed
9
+ * bundle. A deployment has no use for them: coverage and stub inspection are
10
+ * things you do to a development server.
11
+ *
12
+ * The inspector therefore ignores these names wherever it finds them, and
13
+ * `pikku dev` registers the implementations itself. Ignoring by name (rather
14
+ * than deleting the scaffold and requiring every project to regenerate) is what
15
+ * keeps an existing project's checked-in `scenarios.gen.ts` from deploying.
16
+ */
17
+ export const SCENARIO_INSTRUMENTATION_FUNCTIONS = [
18
+ 'pikkuScenarioTakeLiveCoverage',
19
+ 'pikkuScenarioResetLiveCoverage',
20
+ 'pikkuScenarioResetStubs',
21
+ 'pikkuScenarioGetStubCalls',
22
+ ];
23
+ const names = new Set(SCENARIO_INSTRUMENTATION_FUNCTIONS);
24
+ /**
25
+ * True for a scenario instrumentation function, by name. Versioned ids
26
+ * (`name@v2`) are matched on their base so a project that bumps one is still
27
+ * covered.
28
+ */
29
+ export const isScenarioInstrumentationFunction = (name) => (name ? names.has(name.split('@')[0]) : false);
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@ export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js';
5
5
  export { ErrorCode } from './error-codes.js';
6
6
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
7
7
  export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js';
8
+ export { SCENARIO_INSTRUMENTATION_FUNCTIONS, isScenarioInstrumentationFunction, } from './add/scenario-instrumentation.js';
9
+ export type { ScenarioInstrumentationFunction } from './add/scenario-instrumentation.js';
8
10
  export { serializeInspectorState, deserializeInspectorState, } from './utils/serialize-inspector-state.js';
9
11
  export type { SerializableInspectorState } from './utils/serialize-inspector-state.js';
10
12
  export { filterInspectorState } from './utils/filter-inspector-state.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { inspect, getInitialInspectorState } from './inspector.js';
2
2
  export { ErrorCode } from './error-codes.js';
3
3
  export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js';
4
+ export { SCENARIO_INSTRUMENTATION_FUNCTIONS, isScenarioInstrumentationFunction, } from './add/scenario-instrumentation.js';
4
5
  export { serializeInspectorState, deserializeInspectorState, } from './utils/serialize-inspector-state.js';
5
6
  export { filterInspectorState } from './utils/filter-inspector-state.js';
6
7
  export { resolveDeployTarget } from './utils/resolve-deploy-target.js';
@@ -212,6 +212,13 @@ export function aggregateRequiredServices(state) {
212
212
  for (const [, graph] of Object.entries(state.workflows.graphMeta)) {
213
213
  if (!graph.nodes || !graph.name)
214
214
  continue;
215
+ // A scenario is a workflow, but it only ever runs in-process under
216
+ // `pikku scenario run` — no step of one is dispatched through a queue. The
217
+ // synthetic entries were pure leakage: they put a
218
+ // `wf-orchestrator-<scenario>` worker into the app's queue meta, which every
219
+ // bundle imports and a provider then creates as a real production queue.
220
+ if (graph.source === 'scenario')
221
+ continue;
215
222
  const toKebab = (s) => s
216
223
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
217
224
  .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
@@ -1,5 +1,29 @@
1
1
  import type { JSONValue } from '@pikku/core';
2
2
  import type { InspectorLogger, InspectorState } from '../types.js';
3
+ /** One file the generated schemas were derived from, as of when they were generated. */
4
+ export type SchemaDep = {
5
+ path: string;
6
+ mtimeMs: number;
7
+ size: number;
8
+ };
9
+ export declare function readDiskTSSchemas(logger: InspectorLogger, cacheDir: string, key: string): {
10
+ schemas: Record<string, JSONValue>;
11
+ deps: SchemaDep[];
12
+ } | null;
13
+ export declare function writeDiskTSSchemas(logger: InspectorLogger, cacheDir: string, key: string, schemas: Record<string, JSONValue>, deps: SchemaDep[]): void;
14
+ /**
15
+ * Where the RUNTIME value of a schema lives, given where TypeScript resolved it.
16
+ *
17
+ * A schema imported from a built workspace package resolves to that package's
18
+ * declaration file, and importing a `.d.ts` yields a module with no exports at
19
+ * all — every one of them is a type. The value is in the emitted JS beside it, so
20
+ * that is what gets imported.
21
+ *
22
+ * Falls back to the original path when no sibling JS exists, which keeps the
23
+ * "could not find exported schema" error pointing at the file the developer
24
+ * actually wrote.
25
+ */
26
+ export declare const schemaRuntimeFile: (sourceFile: string) => string;
3
27
  export declare function generateAllSchemas(logger: InspectorLogger, config: {
4
28
  tsconfig: string;
5
29
  schemasFromTypes?: string[];
@@ -1,6 +1,6 @@
1
1
  import * as ts from 'typescript';
2
2
  import { createHash } from 'crypto';
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'fs';
4
4
  import { dirname, join, resolve } from 'path';
5
5
  import { createGenerator, RootlessError } from 'ts-json-schema-generator';
6
6
  import { register } from 'tsx/esm/api';
@@ -57,6 +57,7 @@ let cachedParsedConfig;
57
57
  let cachedTsconfigPath;
58
58
  let cachedCustomTypesContent;
59
59
  let cachedTSSchemas;
60
+ let cachedTSSchemaDeps;
60
61
  const SCHEMA_CACHE_VERSION = 1;
61
62
  // This package's own version — folded into the cache key so that upgrading
62
63
  // @pikku/inspector (the channel through which a schema-format change ships)
@@ -90,25 +91,93 @@ function tsSchemaCacheKey(customTypesContent, config) {
90
91
  function schemaCacheFile(cacheDir) {
91
92
  return join(cacheDir, 'ts-schemas.json');
92
93
  }
93
- function readDiskTSSchemas(logger, cacheDir, key) {
94
+ /**
95
+ * The files a set of generated schemas was actually derived from.
96
+ *
97
+ * The cache key alone cannot answer whether the schemas are stale. It hashes the
98
+ * *synthesized* custom-types source, but a named type — `KnowledgeBundle`,
99
+ * `DbSchema`, anything a function declares as its input or output — appears
100
+ * there only as a name; its definition is resolved out of a project file or a
101
+ * dependency's `.d.ts`. Edit one of those and the key is unchanged, so a stale
102
+ * schema is served for a type that no longer looks like that. The failure is
103
+ * silent and outlives `rm -rf .pikku`, because this cache lives in
104
+ * `node_modules/.cache`.
105
+ *
106
+ * The program is rooted at the virtual file, so its source files are exactly the
107
+ * transitive closure the schemas depend on — nothing wider, nothing missed.
108
+ * Default-lib files are skipped: they change only when TypeScript itself does,
109
+ * which the inspector version in the key already covers.
110
+ */
111
+ function fingerprintSchemaDeps(program) {
112
+ const deps = [];
113
+ for (const sourceFile of program.getSourceFiles()) {
114
+ if (program.isSourceFileDefaultLibrary(sourceFile))
115
+ continue;
116
+ try {
117
+ const stat = statSync(sourceFile.fileName);
118
+ deps.push({
119
+ path: sourceFile.fileName,
120
+ mtimeMs: stat.mtimeMs,
121
+ size: stat.size,
122
+ });
123
+ }
124
+ catch {
125
+ // The virtual root has no file on disk. Its content is the custom-types
126
+ // source, which the cache key already hashes.
127
+ }
128
+ }
129
+ return deps;
130
+ }
131
+ /**
132
+ * Whether every file the schemas were derived from is still exactly as it was.
133
+ *
134
+ * mtime-and-size rather than a content hash: this runs on the cache-hit path,
135
+ * where the whole point is to answer in milliseconds without building a TS
136
+ * program. Anything that rewrites a file — a dependency rebuild, a checkout, an
137
+ * edit — moves its mtime, and a missing file reads as changed, so the answer errs
138
+ * toward regenerating.
139
+ */
140
+ function schemaDepsUnchanged(deps) {
141
+ if (!Array.isArray(deps))
142
+ return false;
143
+ for (const dep of deps) {
144
+ if (!dep || typeof dep.path !== 'string')
145
+ return false;
146
+ try {
147
+ const stat = statSync(dep.path);
148
+ if (stat.mtimeMs !== dep.mtimeMs || stat.size !== dep.size)
149
+ return false;
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ return true;
156
+ }
157
+ export function readDiskTSSchemas(logger, cacheDir, key) {
94
158
  const file = schemaCacheFile(cacheDir);
95
159
  if (!existsSync(file))
96
160
  return null;
97
161
  try {
98
162
  const parsed = JSON.parse(readFileSync(file, 'utf-8'));
99
- if (parsed?.key === key && parsed.schemas)
100
- return parsed.schemas;
163
+ if (parsed?.key !== key || !parsed.schemas)
164
+ return null;
165
+ if (!schemaDepsUnchanged(parsed.deps)) {
166
+ logger.debug('Discarding TS-schema cache: a type it was built from changed');
167
+ return null;
168
+ }
169
+ return { schemas: parsed.schemas, deps: parsed.deps };
101
170
  }
102
171
  catch (e) {
103
172
  logger.debug(`Ignoring unreadable TS-schema cache: ${e.message}`);
104
173
  }
105
174
  return null;
106
175
  }
107
- function writeDiskTSSchemas(logger, cacheDir, key, schemas) {
176
+ export function writeDiskTSSchemas(logger, cacheDir, key, schemas, deps) {
108
177
  const file = schemaCacheFile(cacheDir);
109
178
  try {
110
179
  mkdirSync(dirname(file), { recursive: true });
111
- writeFileSync(file, JSON.stringify({ key, schemas }));
180
+ writeFileSync(file, JSON.stringify({ key, schemas, deps }));
112
181
  }
113
182
  catch (e) {
114
183
  logger.debug(`Failed to persist TS-schema cache: ${e.message}`);
@@ -196,7 +265,7 @@ function generateTSSchemas(logger, tsconfig, customTypesContent, typesMap, funct
196
265
  // so that failed Zod conversions fall through to TS schema generation.
197
266
  const uncoveredSchemas = [...schemasSet].filter((s) => !PRIMITIVE_TYPES.has(s) && !generatedZodSchemas?.[s]);
198
267
  if (uncoveredSchemas.length === 0) {
199
- return {};
268
+ return { schemas: {}, deps: [] };
200
269
  }
201
270
  logger.debug(`generateTSSchemas needed for ${uncoveredSchemas.length} types: ${uncoveredSchemas.slice(0, 3).join(', ')}${uncoveredSchemas.length > 3 ? '...' : ''}`);
202
271
  const virtualFilePath = join(dirname(resolve(tsconfig)), '__pikku_virtual_types__.ts');
@@ -239,8 +308,29 @@ function generateTSSchemas(logger, tsconfig, customTypesContent, typesMap, funct
239
308
  logger.error(`[${ErrorCode.SCHEMA_GENERATION_ERROR}] Error generating schema: ${schema}. Message: ${e.message}. Type info: ${customType ? `type=${customType.type}` : 'not in typesMap'}`);
240
309
  }
241
310
  });
242
- return schemas;
311
+ // Fingerprinted here, while the program is still alive — it is released as soon
312
+ // as the schemas are out of it.
313
+ return { schemas, deps: fingerprintSchemaDeps(program) };
243
314
  }
315
+ /**
316
+ * Where the RUNTIME value of a schema lives, given where TypeScript resolved it.
317
+ *
318
+ * A schema imported from a built workspace package resolves to that package's
319
+ * declaration file, and importing a `.d.ts` yields a module with no exports at
320
+ * all — every one of them is a type. The value is in the emitted JS beside it, so
321
+ * that is what gets imported.
322
+ *
323
+ * Falls back to the original path when no sibling JS exists, which keeps the
324
+ * "could not find exported schema" error pointing at the file the developer
325
+ * actually wrote.
326
+ */
327
+ export const schemaRuntimeFile = (sourceFile) => {
328
+ const match = /\.d\.([cm]?)ts$/.exec(sourceFile);
329
+ if (!match)
330
+ return sourceFile;
331
+ const emitted = sourceFile.replace(/\.d\.([cm]?)ts$/, '.$1js');
332
+ return existsSync(emitted) ? emitted : sourceFile;
333
+ };
244
334
  /**
245
335
  * Import all source files in parallel using tsx's register() API.
246
336
  *
@@ -333,7 +423,7 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
333
423
  }
334
424
  // Collect unique source files and batch-import them in parallel
335
425
  const uniqueSourceFiles = [
336
- ...new Set([...schemaLookup.values()].map((ref) => ref.sourceFile)),
426
+ ...new Set([...schemaLookup.values()].map((ref) => schemaRuntimeFile(ref.sourceFile))),
337
427
  ];
338
428
  logger.debug(`[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`);
339
429
  const importStart = performance.now();
@@ -343,11 +433,12 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
343
433
  // Track schemas that need per-file tsImport fallback
344
434
  const fallbackSchemas = [];
345
435
  for (const [schemaName, ref] of schemaLookup.entries()) {
346
- const mod = importedModules?.get(ref.sourceFile);
436
+ const runtimeFile = schemaRuntimeFile(ref.sourceFile);
437
+ const mod = importedModules?.get(runtimeFile);
347
438
  if (mod) {
348
439
  const zodSchema = mod[ref.variableName];
349
440
  if (!zodSchema) {
350
- errors.push(`Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`);
441
+ errors.push(`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`);
351
442
  continue;
352
443
  }
353
444
  try {
@@ -368,11 +459,12 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
368
459
  if (fallbackSchemas.length > 0) {
369
460
  logger.debug(`Falling back to register() import for ${fallbackSchemas.length} schema(s)`);
370
461
  for (const [schemaName, ref] of fallbackSchemas) {
462
+ const runtimeFile = schemaRuntimeFile(ref.sourceFile);
371
463
  try {
372
- const module = await importWithRegister(ref.sourceFile);
464
+ const module = await importWithRegister(runtimeFile);
373
465
  const zodSchema = module[ref.variableName];
374
466
  if (!zodSchema) {
375
- errors.push(`Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`);
467
+ errors.push(`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`);
376
468
  continue;
377
469
  }
378
470
  processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
@@ -395,8 +487,13 @@ export async function generateAllSchemas(logger, config, state) {
395
487
  const zodSchemas = await generateZodSchemas(logger, state.schemaLookup, state.functions.typesMap);
396
488
  const requiredTypes = new Set();
397
489
  const customTypesContent = generateCustomTypes(state.functions.typesMap, requiredTypes);
398
- // Fast path: same process, types unchanged — reuse the in-memory result.
399
- if (cachedTSSchemas && cachedCustomTypesContent === customTypesContent) {
490
+ // Fast path: same process, types unchanged — reuse the in-memory result. The
491
+ // dep check is what makes this correct under `pikku dev`, where the process
492
+ // outlives the edits: the custom-types source is identical whenever a named
493
+ // type changes shape without changing its name.
494
+ if (cachedTSSchemas &&
495
+ cachedCustomTypesContent === customTypesContent &&
496
+ schemaDepsUnchanged(cachedTSSchemaDeps)) {
400
497
  logger.debug('Reusing cached TS schemas (types unchanged)');
401
498
  return { ...cachedTSSchemas, ...zodSchemas };
402
499
  }
@@ -411,11 +508,12 @@ export async function generateAllSchemas(logger, config, state) {
411
508
  if (disk) {
412
509
  logger.debug('Reusing on-disk TS schemas (types unchanged across runs)');
413
510
  cachedCustomTypesContent = customTypesContent;
414
- cachedTSSchemas = disk;
415
- return { ...disk, ...zodSchemas };
511
+ cachedTSSchemas = disk.schemas;
512
+ cachedTSSchemaDeps = disk.deps;
513
+ return { ...disk.schemas, ...zodSchemas };
416
514
  }
417
515
  }
418
- const tsSchemas = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
516
+ const { schemas: tsSchemas, deps: tsSchemaDeps } = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
419
517
  // Release the program once the schemas are out of it. It spans the whole
420
518
  // tsconfig (~2.5k files, ~600MB on a large tree) and was previously pinned at
421
519
  // module scope for the life of the process, so it stacked on top of the main
@@ -425,8 +523,9 @@ export async function generateAllSchemas(logger, config, state) {
425
523
  cachedSchemaProgram = undefined;
426
524
  cachedCustomTypesContent = customTypesContent;
427
525
  cachedTSSchemas = tsSchemas;
526
+ cachedTSSchemaDeps = tsSchemaDeps;
428
527
  if (config.cacheDir && cacheKey) {
429
- writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas);
528
+ writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas, tsSchemaDeps);
430
529
  }
431
530
  return { ...tsSchemas, ...zodSchemas };
432
531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.48",
3
+ "version": "0.12.50",
4
4
  "description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "BUSL-1.1",
@@ -45,10 +45,10 @@
45
45
  "zod-to-ts": "^2.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@pikku/core": "^0.12.70"
48
+ "@pikku/core": "^0.12.72"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.70",
51
+ "@pikku/core": "^0.12.72",
52
52
  "@types/node": "^24.11.0"
53
53
  },
54
54
  "engines": {