@platforma-sdk/block-tools 2.10.17 → 2.10.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/block-tools",
3
- "version": "2.10.17",
3
+ "version": "2.10.18",
4
4
  "description": "Utility to manipulate Platforma Blocks and Block Registry",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -33,12 +33,12 @@
33
33
  "undici": "~7.16.0",
34
34
  "yaml": "^2.8.0",
35
35
  "zod": "~3.25.76",
36
+ "@milaboratories/pl-http": "1.2.4",
37
+ "@milaboratories/pl-model-middle-layer": "1.30.6",
36
38
  "@milaboratories/pl-model-common": "1.46.1",
37
- "@milaboratories/pl-model-backend": "1.4.7",
38
39
  "@milaboratories/resolve-helper": "1.1.3",
39
- "@milaboratories/pl-model-middle-layer": "1.30.6",
40
+ "@milaboratories/pl-model-backend": "1.4.7",
40
41
  "@milaboratories/ts-helpers": "1.8.3",
41
- "@milaboratories/pl-http": "1.2.4",
42
42
  "@milaboratories/ts-helpers-oclif": "1.1.42",
43
43
  "@platforma-sdk/blocks-deps-updater": "2.2.0"
44
44
  },
@@ -56,10 +56,10 @@
56
56
  "vite": "^8.0.6",
57
57
  "vite-plugin-dts": "^4.5.3",
58
58
  "vitest": "^4.1.3",
59
- "@milaboratories/build-configs": "2.0.0",
60
59
  "@milaboratories/oclif-index": "1.1.1",
61
60
  "@milaboratories/ts-configs": "1.2.3",
62
- "@milaboratories/ts-builder": "1.5.1"
61
+ "@milaboratories/ts-builder": "1.5.2",
62
+ "@milaboratories/build-configs": "2.0.0"
63
63
  },
64
64
  "oclif": {
65
65
  "bin": "block-tools",
@@ -34,8 +34,7 @@ const EXPECTED_MODEL_PACKAGE_JSON = `{
34
34
  "devDependencies": {
35
35
  "@milaboratories/ts-builder": "catalog:",
36
36
  "@milaboratories/ts-configs": "catalog:",
37
- "@platforma-sdk/block-tools": "catalog:",
38
- "vitest": "catalog:"
37
+ "@platforma-sdk/block-tools": "catalog:"
39
38
  },
40
39
  "peerDependencies": {
41
40
  "@types/node": "*",
@@ -11,8 +11,12 @@ import { NodeTemplateProvider } from "../engine/templates";
11
11
  import { run as engineRun, type Change } from "../engine/runner";
12
12
  import { discoverRunContext } from "../engine/discovery-fs";
13
13
  import { STRUCTURE } from "../structure-definition";
14
- import { matchesBumpPattern, buildRegistryLookupForNames } from "../engine/registry-client";
15
- import { SDK_CATALOG_PACKAGES } from "../rules/root-pnpm-workspace";
14
+ import {
15
+ matchesBumpPattern,
16
+ buildRegistryLookupForNames,
17
+ buildDerivedPinLookupForPins,
18
+ } from "../engine/registry-client";
19
+ import { SDK_CATALOG_PACKAGES, DERIVED_CATALOG_PINS } from "../rules/root-pnpm-workspace";
16
20
 
17
21
  export type StructureMode = "check" | "refresh";
18
22
 
@@ -60,10 +64,16 @@ export async function runStructureForPath(input: RunStructureInput): Promise<Run
60
64
  });
61
65
 
62
66
  const registryLookup = input.updateDepsOnly ? await buildRegistryLookup() : undefined;
67
+ // Derived catalog pins (e.g. vue ← ui-vue's declared dep) resolve on the
68
+ // same prefetch budget as the SDK latest bump; absent on a default refresh.
69
+ const derivedPinLookup = input.updateDepsOnly
70
+ ? await buildDerivedPinLookupForPins(DERIVED_CATALOG_PINS)
71
+ : undefined;
63
72
 
64
73
  const result = engineRun(STRUCTURE, fs, ctx, {
65
74
  templates,
66
75
  registryLookup,
76
+ derivedPinLookup,
67
77
  // Fresh re-discovery for the post-run recheck (default refresh only)
68
78
  // — re-reads the just-written tree so a rename that shifted the
69
79
  // module map is observed.
@@ -0,0 +1,65 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { pinCatalogToDependencyOf, withManagedYaml } from "../../content-rules";
3
+ import { parseYaml, stringifyYaml } from "../../parsers/yaml";
4
+
5
+ // The builder is sync: it reads a value the runner pre-resolved + validated
6
+ // into `getDerivedDependencyPin`. These tests inject that accessor directly.
7
+ const lookup =
8
+ (m: Record<string, string>) =>
9
+ (entry: string): string | undefined =>
10
+ m[entry];
11
+
12
+ describe("pinCatalogToDependencyOf (mocked derived-pin lookup)", () => {
13
+ test("overwrites a present LOOSE entry with the derived exact version", () => {
14
+ const doc = parseYaml("catalog:\n vue: ^3.5.24\n");
15
+ withManagedYaml(doc, () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }), {
16
+ getDerivedDependencyPin: lookup({ vue: "3.5.24" }),
17
+ });
18
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
19
+ expect(cat.vue).toBe("3.5.24");
20
+ });
21
+
22
+ test("creates an absent entry", () => {
23
+ const doc = parseYaml("catalog:\n unrelated: 1.0.0\n");
24
+ withManagedYaml(doc, () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }), {
25
+ getDerivedDependencyPin: lookup({ vue: "3.5.24" }),
26
+ });
27
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
28
+ expect(cat.vue).toBe("3.5.24");
29
+ expect(cat.unrelated).toBe("1.0.0");
30
+ });
31
+
32
+ test("no-op when no derived-pin lookup is provided (default refresh)", () => {
33
+ const doc = parseYaml("catalog:\n vue: ^3.5.24\n");
34
+ withManagedYaml(doc, () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }));
35
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
36
+ expect(cat.vue).toBe("^3.5.24");
37
+ });
38
+
39
+ test("no-op when the entry was not prefetched (lookup returns undefined)", () => {
40
+ const doc = parseYaml("catalog:\n vue: ^3.5.24\n");
41
+ withManagedYaml(doc, () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }), {
42
+ getDerivedDependencyPin: lookup({ other: "1.0.0" }),
43
+ });
44
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
45
+ expect(cat.vue).toBe("^3.5.24");
46
+ });
47
+
48
+ test("idempotent — second run produces the same YAML", () => {
49
+ const doc = parseYaml("catalog:\n vue: ^3.5.24\n");
50
+ const opts = { getDerivedDependencyPin: lookup({ vue: "3.5.24" }) };
51
+ withManagedYaml(
52
+ doc,
53
+ () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }),
54
+ opts,
55
+ );
56
+ const once = stringifyYaml(doc);
57
+ withManagedYaml(
58
+ doc,
59
+ () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }),
60
+ opts,
61
+ );
62
+ const twice = stringifyYaml(doc);
63
+ expect(twice).toBe(once);
64
+ });
65
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { buildDerivedPinLookupForPins, createMockRegistryClient } from "../registry-client";
3
+
4
+ describe("buildDerivedPinLookupForPins (mocked registry)", () => {
5
+ test("resolves the catalog entry to the source's declared exact dep version", async () => {
6
+ const client = createMockRegistryClient(
7
+ { "@platforma-sdk/ui-vue": "1.79.6" },
8
+ { "@platforma-sdk/ui-vue@1.79.6": { vue: "3.5.24" } },
9
+ );
10
+ const lookup = await buildDerivedPinLookupForPins(
11
+ [{ entry: "vue", of: "@platforma-sdk/ui-vue" }],
12
+ client,
13
+ );
14
+ expect(lookup("vue")).toBe("3.5.24");
15
+ expect(lookup("unrelated")).toBeUndefined();
16
+ });
17
+
18
+ test("reads from a pinned source version when `ofVersion` is given", async () => {
19
+ const client = createMockRegistryClient(
20
+ { "@platforma-sdk/ui-vue": "9.9.9" }, // latest — must NOT be used
21
+ { "@platforma-sdk/ui-vue@1.79.3": { vue: "3.5.20" } },
22
+ );
23
+ const lookup = await buildDerivedPinLookupForPins(
24
+ [{ entry: "vue", of: "@platforma-sdk/ui-vue", ofVersion: "1.79.3" }],
25
+ client,
26
+ );
27
+ expect(lookup("vue")).toBe("3.5.20");
28
+ });
29
+
30
+ test("empty pin list → lookup returns undefined for everything", async () => {
31
+ const lookup = await buildDerivedPinLookupForPins([], createMockRegistryClient({}));
32
+ expect(lookup("vue")).toBeUndefined();
33
+ });
34
+
35
+ test("policy (b): throws when the source declares a NON-EXACT range", async () => {
36
+ const client = createMockRegistryClient(
37
+ { "@platforma-sdk/ui-vue": "1.79.6" },
38
+ { "@platforma-sdk/ui-vue@1.79.6": { vue: "^3.5.24" } },
39
+ );
40
+ await expect(
41
+ buildDerivedPinLookupForPins([{ entry: "vue", of: "@platforma-sdk/ui-vue" }], client),
42
+ ).rejects.toThrow(/not an exact version/i);
43
+ });
44
+
45
+ test("policy (b): throws when the source declares no such dependency", async () => {
46
+ const client = createMockRegistryClient(
47
+ { "@platforma-sdk/ui-vue": "1.79.6" },
48
+ { "@platforma-sdk/ui-vue@1.79.6": { react: "19.0.0" } },
49
+ );
50
+ await expect(
51
+ buildDerivedPinLookupForPins([{ entry: "vue", of: "@platforma-sdk/ui-vue" }], client),
52
+ ).rejects.toThrow(/declares no/i);
53
+ });
54
+ });
@@ -117,6 +117,7 @@ export {
117
117
  ensureWorkspaceModulePaths,
118
118
  ensureCatalogVersion,
119
119
  pinCatalogTo,
120
+ pinCatalogToDependencyOf,
120
121
  ensureCatalogLatest,
121
122
  ensureCatalogAbsent,
122
123
  // Lines managed builders
@@ -83,6 +83,9 @@ type YamlState = {
83
83
  triggerContext?: TriggerContext;
84
84
  /** Synchronous accessor for prefetched npm latest versions. */
85
85
  getLatestVersion?: (packageName: string) => string | undefined;
86
+ /** Synchronous accessor for prefetched derived catalog pins
87
+ * (`catalogEntry → exact version`); see `pinCatalogToDependencyOf`. */
88
+ getDerivedDependencyPin?: (catalogEntry: string) => string | undefined;
86
89
  };
87
90
 
88
91
  type LinesState = {
@@ -177,6 +180,9 @@ export type WithManagedYamlOpts = WithManagedOpts & {
177
180
  /** Sync accessor for prefetched latest versions. Required for
178
181
  * `ensureCatalogLatest` to take effect. */
179
182
  getLatestVersion?: (packageName: string) => string | undefined;
183
+ /** Sync accessor for prefetched derived catalog pins. Required for
184
+ * `pinCatalogToDependencyOf` to take effect. */
185
+ getDerivedDependencyPin?: (catalogEntry: string) => string | undefined;
180
186
  };
181
187
 
182
188
  /** Engine-internal: run `body` with `doc` as the active YAML state (for
@@ -193,6 +199,7 @@ export function withManagedYaml(
193
199
  ctx: opts.ctx,
194
200
  triggerContext: opts.triggerContext,
195
201
  getLatestVersion: opts.getLatestVersion,
202
+ getDerivedDependencyPin: opts.getDerivedDependencyPin,
196
203
  };
197
204
  try {
198
205
  body();
@@ -579,6 +586,28 @@ export function ensureCatalogLatest(name: string): void {
579
586
  state.doc.setIn(["catalog", name], latest);
580
587
  }
581
588
 
589
+ /** Pin `catalog.<catalogEntry>` to the EXACT version that package `opts.of`
590
+ * declares for `catalogEntry` in its own manifest (read at npm latest, or at
591
+ * `opts.ofVersion` if given). The value is resolved by the runner's
592
+ * prefetched `getDerivedDependencyPin` accessor (the caller pre-resolves and
593
+ * validates — exact-version-only — at prefetch time; no network here).
594
+ * OVERWRITES like `pinCatalogTo` (not add-if-absent): a pre-existing loose
595
+ * entry (`vue: ^3.5.24`) is tightened to the exact derived version. No-op
596
+ * when no derived-pin lookup is available — default refresh/check pass none,
597
+ * leaving the entry exactly as the lockfile has it; the resolution leaves
598
+ * live in the `onInitOrUpdate` frame. */
599
+ export function pinCatalogToDependencyOf(
600
+ catalogEntry: string,
601
+ _opts: { of: string; ofVersion?: string },
602
+ ): void {
603
+ const state = requireYaml("pinCatalogToDependencyOf");
604
+ if (!state.getDerivedDependencyPin) return;
605
+ const version = state.getDerivedDependencyPin(catalogEntry);
606
+ if (version === undefined) return;
607
+ if (readCatalogString(state, catalogEntry) === version) return;
608
+ state.doc.setIn(["catalog", catalogEntry], version);
609
+ }
610
+
582
611
  /** Remove `catalog.<name>` if present; no-op when absent. */
583
612
  export function ensureCatalogAbsent(name: string): void {
584
613
  const state = requireYaml("ensureCatalogAbsent");
@@ -12,6 +12,15 @@ import { setTimeout as sleep } from "node:timers/promises";
12
12
 
13
13
  export interface RegistryClient {
14
14
  getLatestVersion(packageName: string): Promise<string>;
15
+ /** Read the version `packageName@version` declares for `depName` in its
16
+ * published manifest. `version` may be "latest" (resolved via the
17
+ * dist-tags). Looks in `dependencies` first, then `peerDependencies`.
18
+ * Returns `undefined` when the dep is declared in neither section. */
19
+ getDeclaredDependency(
20
+ packageName: string,
21
+ version: string,
22
+ depName: string,
23
+ ): Promise<string | undefined>;
15
24
  }
16
25
 
17
26
  const RETRY_COUNT = 2;
@@ -60,6 +69,14 @@ async function fetchWithRetry(url: string): Promise<Response> {
60
69
  }
61
70
  }
62
71
 
72
+ /** Shape of the per-version manifest doc fetched from
73
+ * `https://registry.npmjs.org/<pkg>/<version>` (only the dep sections are
74
+ * read). */
75
+ type PackageManifest = {
76
+ dependencies?: Record<string, string>;
77
+ peerDependencies?: Record<string, string>;
78
+ };
79
+
63
80
  /** Live npm registry client. Hits the network on each call. */
64
81
  export function createRealRegistryClient(): RegistryClient {
65
82
  return {
@@ -74,11 +91,33 @@ export function createRealRegistryClient(): RegistryClient {
74
91
  }
75
92
  return latest;
76
93
  },
94
+ async getDeclaredDependency(
95
+ packageName: string,
96
+ version: string,
97
+ depName: string,
98
+ ): Promise<string | undefined> {
99
+ const res = await fetchWithRetry(`https://registry.npmjs.org/${packageName}/${version}`);
100
+ const manifest = (await res.json()) as PackageManifest;
101
+ return manifest.dependencies?.[depName] ?? manifest.peerDependencies?.[depName];
102
+ },
77
103
  };
78
104
  }
79
105
 
80
- /** In-memory client backed by a fixed `name → version` map. */
81
- export function createMockRegistryClient(versions: Record<string, string>): RegistryClient {
106
+ /** In-memory client backed by a fixed `name → version` map. The optional
107
+ * `manifests` map (`pkg@version` → declared deps) backs
108
+ * `getDeclaredDependency`; "latest" keys resolve via the `versions` map. */
109
+ export function createMockRegistryClient(
110
+ versions: Record<string, string>,
111
+ manifests: Record<string, Record<string, string>> = {},
112
+ ): RegistryClient {
113
+ function resolveVersion(packageName: string, version: string): string {
114
+ if (version !== "latest") return version;
115
+ const v = versions[packageName];
116
+ if (v === undefined) {
117
+ throw new Error(`mock registry: no version configured for '${packageName}'`);
118
+ }
119
+ return v;
120
+ }
82
121
  return {
83
122
  async getLatestVersion(packageName: string): Promise<string> {
84
123
  const v = versions[packageName];
@@ -87,6 +126,18 @@ export function createMockRegistryClient(versions: Record<string, string>): Regi
87
126
  }
88
127
  return v;
89
128
  },
129
+ async getDeclaredDependency(
130
+ packageName: string,
131
+ version: string,
132
+ depName: string,
133
+ ): Promise<string | undefined> {
134
+ const resolved = resolveVersion(packageName, version);
135
+ const deps = manifests[`${packageName}@${resolved}`];
136
+ if (deps === undefined) {
137
+ throw new Error(`mock registry: no manifest configured for '${packageName}@${resolved}'`);
138
+ }
139
+ return deps[depName];
140
+ },
90
141
  };
91
142
  }
92
143
 
@@ -138,3 +189,66 @@ export async function buildRegistryLookupForNames(
138
189
  const resolved = await prefetchLatestVersions(client, names);
139
190
  return makeSyncLookup(resolved);
140
191
  }
192
+
193
+ /** A catalog entry whose version is DERIVED from the version another
194
+ * package declares for it. `entry` is the catalog key (== the dep name in
195
+ * the source manifest); `of` is the package whose manifest is read; the
196
+ * optional `ofVersion` pins which release of `of` to read (default: npm
197
+ * latest, the same release the catalog bump pins `of` to). Consumed by
198
+ * `pinCatalogToDependencyOf`. */
199
+ export type DerivedCatalogPin = {
200
+ entry: string;
201
+ of: string;
202
+ ofVersion?: string;
203
+ };
204
+
205
+ /** True iff `version` is a bare exact `x.y.z(-prerelease)?` — no range
206
+ * modifier (`^`, `~`, `>=`, `*`, `||`, ` - `, `x`, workspace:, etc.). */
207
+ function isExactVersion(version: string): boolean {
208
+ return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version);
209
+ }
210
+
211
+ /** Prefetch every derived pin and build the sync lookup the runner passes
212
+ * to `pinCatalogToDependencyOf` (`entry → exact version`). Reads
213
+ * `pin.of`'s manifest (at `pin.ofVersion`, default npm latest) and the
214
+ * version it declares for `pin.entry`. Policy (b): THROWS at prefetch time
215
+ * if the source declares the dep as a non-exact (ranged) version or does
216
+ * not declare it at all — a derived pin must resolve to an exact version
217
+ * or it is a configuration error. Empty list → a lookup that resolves
218
+ * nothing (no network). A registry failure REJECTS. `client` defaults to
219
+ * the live npm client; tests inject a mock. */
220
+ export async function buildDerivedPinLookupForPins(
221
+ pins: readonly DerivedCatalogPin[],
222
+ client: RegistryClient = createRealRegistryClient(),
223
+ ): Promise<(entry: string) => string | undefined> {
224
+ if (pins.length === 0) return () => undefined;
225
+ const entries = await Promise.all(
226
+ pins.map(async (pin) => {
227
+ const declared = await client.getDeclaredDependency(
228
+ pin.of,
229
+ pin.ofVersion ?? "latest",
230
+ pin.entry,
231
+ );
232
+ if (declared === undefined) {
233
+ throw new Error(
234
+ `derived catalog pin '${pin.entry}': package '${pin.of}'` +
235
+ `${pin.ofVersion ? `@${pin.ofVersion}` : " (latest)"} declares no ` +
236
+ `dependency '${pin.entry}'. A derived pin requires the source to ` +
237
+ `declare the dep as an exact version.`,
238
+ );
239
+ }
240
+ if (!isExactVersion(declared)) {
241
+ throw new Error(
242
+ `derived catalog pin '${pin.entry}': package '${pin.of}'` +
243
+ `${pin.ofVersion ? `@${pin.ofVersion}` : " (latest)"} declares ` +
244
+ `'${pin.entry}: ${declared}', which is not an exact version. ` +
245
+ `A derived pin requires an exact ('x.y.z') source dependency so ` +
246
+ `the catalog can pin to it precisely.`,
247
+ );
248
+ }
249
+ return [pin.entry, declared] as const;
250
+ }),
251
+ );
252
+ const resolved = new Map(entries);
253
+ return (entry) => resolved.get(entry);
254
+ }
@@ -83,6 +83,12 @@ export type RunOptions = {
83
83
  * and passes the resulting sync map here; unit tests pass a mock.
84
84
  * Absent → `ensureCatalogLatest` is a no-op (default refresh). */
85
85
  registryLookup?: (packageName: string) => string | undefined;
86
+ /** Sync accessor for prefetched derived catalog pins
87
+ * (`catalogEntry → exact version`), consumed by `pinCatalogToDependencyOf`
88
+ * inside `onInitOrUpdate` managed bodies. The CLI / init constructor
89
+ * prefetches + validates these before the run (network). Absent →
90
+ * `pinCatalogToDependencyOf` is a no-op (default refresh). */
91
+ derivedPinLookup?: (catalogEntry: string) => string | undefined;
86
92
  };
87
93
 
88
94
  export class RecheckError extends Error {
@@ -189,6 +195,7 @@ function runManagedBody(
189
195
  body: ManagedBody,
190
196
  tctx: TriggerContext,
191
197
  registryLookup: ((packageName: string) => string | undefined) | undefined,
198
+ derivedPinLookup: ((catalogEntry: string) => string | undefined) | undefined,
192
199
  ): string {
193
200
  const base = path.split("/").pop() ?? path;
194
201
  if (path.endsWith(".json")) {
@@ -203,7 +210,11 @@ function runManagedBody(
203
210
  }
204
211
  if (path.endsWith(".yaml") || path.endsWith(".yml")) {
205
212
  const doc = parseYaml(raw);
206
- withManagedYaml(doc, body, { triggerContext: tctx, getLatestVersion: registryLookup });
213
+ withManagedYaml(doc, body, {
214
+ triggerContext: tctx,
215
+ getLatestVersion: registryLookup,
216
+ getDerivedDependencyPin: derivedPinLookup,
217
+ });
207
218
  return stringifyYaml(doc);
208
219
  }
209
220
  if (base === ".gitignore" || base.endsWith(".gitignore")) {
@@ -371,6 +382,7 @@ function applyManaged(
371
382
  tctx: TriggerContext,
372
383
  templates: TemplateProvider | undefined,
373
384
  registryLookup: ((packageName: string) => string | undefined) | undefined,
385
+ derivedPinLookup: ((catalogEntry: string) => string | undefined) | undefined,
374
386
  isSdkInternal: boolean,
375
387
  ): Change | undefined {
376
388
  if (item.leaf.kind !== "managed") return undefined;
@@ -388,7 +400,7 @@ function applyManaged(
388
400
  beforeRaw = fs.read(path);
389
401
  }
390
402
 
391
- const after = runManagedBody(path, beforeRaw, leaf.body, tctx, registryLookup);
403
+ const after = runManagedBody(path, beforeRaw, leaf.body, tctx, registryLookup, derivedPinLookup);
392
404
 
393
405
  if (created) {
394
406
  if (!dryRun) fs.write(path, after);
@@ -419,6 +431,7 @@ function executePass(
419
431
  templates: TemplateProvider | undefined,
420
432
  initMode: boolean,
421
433
  registryLookup: ((packageName: string) => string | undefined) | undefined,
434
+ derivedPinLookup: ((catalogEntry: string) => string | undefined) | undefined,
422
435
  ): Change[] {
423
436
  const items = filterByMode(flat, currentMode(ctx, initMode));
424
437
  const changes: Change[] = [];
@@ -448,6 +461,7 @@ function executePass(
448
461
  tctx2,
449
462
  templates,
450
463
  registryLookup,
464
+ derivedPinLookup,
451
465
  ctx.isSdkInternal,
452
466
  );
453
467
  if (change) changes.push(change);
@@ -478,6 +492,7 @@ export function run(
478
492
  opts.templates,
479
493
  initMode,
480
494
  opts.registryLookup,
495
+ opts.derivedPinLookup,
481
496
  );
482
497
 
483
498
  const isRefreshDefault = !ctx.dryRun && !ctx.updateDepsOnly && !initMode;
@@ -499,6 +514,7 @@ export function run(
499
514
  opts.templates,
500
515
  false,
501
516
  opts.registryLookup,
517
+ opts.derivedPinLookup,
502
518
  );
503
519
  // Ignore .structure self-write (already up to date after
504
520
  // writeStructureVersion above).
@@ -14,8 +14,12 @@ import { run as engineRun } from "./engine/runner";
14
14
  import { createRunContext, modulesForInit } from "./engine/ctx";
15
15
  import { STRUCTURE } from "./structure-definition";
16
16
  import { STRUCTURE_VERSION } from "./engine/version";
17
- import { matchesBumpPattern, buildRegistryLookupForNames } from "./engine/registry-client";
18
- import { SDK_CATALOG_PACKAGES } from "./rules/root-pnpm-workspace";
17
+ import {
18
+ matchesBumpPattern,
19
+ buildRegistryLookupForNames,
20
+ buildDerivedPinLookupForPins,
21
+ } from "./engine/registry-client";
22
+ import { SDK_CATALOG_PACKAGES, DERIVED_CATALOG_PINS } from "./rules/root-pnpm-workspace";
19
23
  import type { BlockVars } from "./engine/api";
20
24
 
21
25
  /** Platform choices `init` can scaffold a software module for. Python is
@@ -145,8 +149,12 @@ export async function runInit(init: InitInput): Promise<BlockVars> {
145
149
  // aborts init rather than silently shipping an unresolved placeholder.
146
150
  const sdkNames = SDK_CATALOG_PACKAGES.filter(matchesBumpPattern);
147
151
  let registryLookup: (name: string) => string | undefined;
152
+ let derivedPinLookup: (entry: string) => string | undefined;
148
153
  try {
149
154
  registryLookup = await buildRegistryLookupForNames(sdkNames);
155
+ // Derived catalog pins (e.g. vue ← ui-vue's declared dep) are resolved on
156
+ // init too, so a freshly-scaffolded block pins vue exactly from the start.
157
+ derivedPinLookup = await buildDerivedPinLookupForPins(DERIVED_CATALOG_PINS);
150
158
  } catch (err) {
151
159
  throw new Error(
152
160
  `init requires network access to resolve SDK catalog versions from npm, ` +
@@ -154,7 +162,12 @@ export async function runInit(init: InitInput): Promise<BlockVars> {
154
162
  );
155
163
  }
156
164
 
157
- engineRun(STRUCTURE, fs, ctx, { templates, initMode: true, registryLookup });
165
+ engineRun(STRUCTURE, fs, ctx, {
166
+ templates,
167
+ initMode: true,
168
+ registryLookup,
169
+ derivedPinLookup,
170
+ });
158
171
  init.log(
159
172
  `Initialised block '${vars.facadeName}'${
160
173
  vars.softwarePlatform ? ` (software: ${vars.softwarePlatform})` : " (no software)"
@@ -15,6 +15,8 @@ import {
15
15
  ensureDep,
16
16
  ensureDevDep,
17
17
  ensureDevDeps,
18
+ removeDep,
19
+ removeField,
18
20
  ensurePeerDeps,
19
21
  enforceAlphabeticalOrder,
20
22
  enforceFieldOrder,
@@ -49,8 +51,6 @@ export function modelPackageJsonInitial(ctx: RunContext): Record<string, unknown
49
51
  watch: "ts-builder build --target block-model --watch",
50
52
  build: "ts-builder build --target block-model && block-tools build-model",
51
53
  check: "ts-builder check --target block-model",
52
- // No `test`: the vitest `test` script is wired by the body rule ONLY
53
- // when co-located test files exist (a freshly-init'd model has none).
54
54
  },
55
55
  dependencies: {
56
56
  "@platforma-sdk/model": "sdk:",
@@ -59,7 +59,6 @@ export function modelPackageJsonInitial(ctx: RunContext): Record<string, unknown
59
59
  "@milaboratories/ts-builder": "sdk:",
60
60
  "@milaboratories/ts-configs": "sdk:",
61
61
  "@platforma-sdk/block-tools": "sdk:",
62
- vitest: "catalog:",
63
62
  },
64
63
  peerDependencies: {
65
64
  "@types/node": "*",
@@ -94,7 +93,6 @@ export function modelPackageJsonRules(): void {
94
93
  "@milaboratories/ts-builder": "sdk:",
95
94
  "@milaboratories/ts-configs": "sdk:",
96
95
  "@platforma-sdk/block-tools": "sdk:",
97
- vitest: "catalog:",
98
96
  });
99
97
 
100
98
  ensurePeerDeps({
@@ -102,18 +100,39 @@ export function modelPackageJsonRules(): void {
102
100
  typescript: "*",
103
101
  });
104
102
 
105
- // The vitest `test` script + node ambient types are wired ONLY when the
106
- // model carries co-located unit tests (`src/**/*.test.ts`). A test-less
107
- // model gets neither — turbo simply has no `test` task for it, and the
108
- // scope stays a refresh fixpoint. When tests ARE present, `@types/node`
109
- // is promoted from peer to a devDep (the single-section invariant drops
110
- // the peer) so the tests' `node:*` imports resolve under the
111
- // `types: ["node"]` that model/tsconfig adds under the same predicate.
112
- // Runs AFTER ensurePeerDeps so the promotion is not reverted.
113
- when(whenFilesExist(COLOCATED_TEST_GLOB), () => {
114
- ensureScript("test", "vitest run --passWithNoTests");
115
- ensureDevDep("@types/node", "*");
116
- });
103
+ // The vitest `test` script, the `vitest` devDep, and node ambient types are
104
+ // wired ONLY when the model carries co-located unit tests (`src/**/*.test.ts`).
105
+ // A test-less model gets none — turbo simply has no `test` task for it, and
106
+ // the scope stays a refresh fixpoint. When tests ARE present, `@types/node`
107
+ // is promoted from peer to a devDep (the single-section invariant drops the
108
+ // peer) so the tests' `node:*` imports resolve under the `types: ["node"]`
109
+ // that model/tsconfig adds under the same predicate. Runs AFTER ensurePeerDeps
110
+ // so the promotion is not reverted.
111
+ //
112
+ // Skipped entirely for sdk-internal (in-monorepo) blocks: those live inside
113
+ // the platforma monorepo's own test infrastructure (build-configs, shared
114
+ // catalog, coverage providers) and own their test wiring — the structurer
115
+ // must not impose or strip standalone-block test conventions on them.
116
+ when(
117
+ ({ ctx }) => !ctx.isSdkInternal,
118
+ () =>
119
+ when(
120
+ whenFilesExist(COLOCATED_TEST_GLOB),
121
+ () => {
122
+ ensureScript("test", "vitest run --passWithNoTests");
123
+ ensureDevDep("vitest", "catalog:");
124
+ ensureDevDep("@types/node", "*");
125
+ },
126
+ () => removeDep("vitest"),
127
+ ),
128
+ );
129
+
130
+ // vite/tsup-era artefacts: the canonical model builds via ts-builder, so the
131
+ // legacy `tsup`/`vite` devDeps and the top-level `tsup` bundler config block
132
+ // are dropped.
133
+ removeDep("tsup");
134
+ removeDep("vite");
135
+ removeField("tsup");
117
136
 
118
137
  enforceAlphabeticalOrder("dependencies");
119
138
  enforceAlphabeticalOrder("devDependencies");
@@ -26,12 +26,14 @@ import {
26
26
  generate,
27
27
  ensureCatalogLatest,
28
28
  ensureCatalogVersion,
29
+ pinCatalogToDependencyOf,
29
30
  } from "../engine/api";
30
31
  import { getActiveRunContext } from "../engine/builders";
31
32
  import {
32
33
  rootPnpmWorkspaceInitial,
33
34
  SDK_CATALOG_PACKAGES,
34
35
  INFRA_CATALOG_FLOOR,
36
+ DERIVED_CATALOG_PINS,
35
37
  RUNENV_PYTHON,
36
38
  RUNENV_PYTHON_VERSION,
37
39
  } from "./root-pnpm-workspace";
@@ -57,8 +59,17 @@ export function rootCatalogBumpRules(): void {
57
59
  // block's missing standard keys + overwrites the init
58
60
  // placeholder).
59
61
  for (const name of SDK_CATALOG_PACKAGES) ensureCatalogLatest(name);
62
+ // Derived catalog pins → the EXACT version their source package
63
+ // declares (e.g. `vue` from `@platforma-sdk/ui-vue`). OVERWRITES
64
+ // (seeds when absent, tightens a loose on-disk entry). Resolved
65
+ // by the prefetched `derivedPinLookup`; a no-op when absent
66
+ // (default refresh passes none). Must run AFTER ensureCatalogLatest
67
+ // — the source package's own version is bumped there first.
68
+ for (const pin of DERIVED_CATALOG_PINS) {
69
+ pinCatalogToDependencyOf(pin.entry, { of: pin.of, ofVersion: pin.ofVersion });
70
+ }
60
71
  // Curated infra floor → fixed version, ADD-IF-ABSENT (seeds
61
- // shx/turbo/vitest/vue/changesets when missing; never touches a
72
+ // shx/turbo/vitest/changesets when missing; never touches a
62
73
  // version the block already pins).
63
74
  for (const [name, version] of Object.entries(INFRA_CATALOG_FLOOR)) {
64
75
  ensureCatalogVersion(name, version);