@platforma-sdk/block-tools 2.10.16 → 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.
Files changed (34) hide show
  1. package/dist/cli.js +13 -13
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +404 -373
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/structure/engine/api.d.ts +1 -1
  6. package/dist/structure/engine/builders.d.ts +9 -9
  7. package/dist/structure/engine/content-rules.d.ts +17 -0
  8. package/dist/structure/engine/registry-client.d.ts +30 -2
  9. package/dist/structure/engine/runner.d.ts +6 -0
  10. package/dist/structure/rules/root-pnpm-workspace.d.ts +13 -0
  11. package/package.json +5 -5
  12. package/src/structure/__tests__/model-package-json.snapshot.test.ts +1 -2
  13. package/src/structure/__tests__/tsconfig-idempotency.test.ts +94 -0
  14. package/src/structure/cli/run-structure.ts +12 -2
  15. package/src/structure/engine/__tests__/content-rules/pin-catalog-to-dependency-of.test.ts +65 -0
  16. package/src/structure/engine/__tests__/content-rules/when-inner.test.ts +30 -0
  17. package/src/structure/engine/__tests__/derived-pin-lookup.test.ts +54 -0
  18. package/src/structure/engine/api.ts +1 -0
  19. package/src/structure/engine/builders.ts +38 -23
  20. package/src/structure/engine/content-rules.ts +29 -0
  21. package/src/structure/engine/registry-client.ts +116 -2
  22. package/src/structure/engine/runner.ts +18 -2
  23. package/src/structure/init-block-constructor.ts +16 -3
  24. package/src/structure/rules/model-package-json.ts +35 -16
  25. package/src/structure/rules/model.ts +11 -35
  26. package/src/structure/rules/root-catalog-bump.ts +12 -1
  27. package/src/structure/rules/root-package-json.ts +7 -5
  28. package/src/structure/rules/root-pnpm-workspace.ts +24 -8
  29. package/src/structure/rules/ui-package-json.ts +34 -13
  30. package/src/structure/rules/ui.ts +10 -22
  31. package/src/structure/rules/workflow-package-json.ts +25 -11
  32. package/src/structure/rules/workflow.ts +21 -2
  33. package/src/structure/templates/static/model/tsconfig.node.json +7 -0
  34. package/src/structure/templates/static/ui/tsconfig.node.json +7 -0
@@ -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");
@@ -2,47 +2,23 @@
2
2
  // `src/index.ts` seed is dropped by `init` and never touched again
3
3
  // (block author owns it).
4
4
 
5
- import {
6
- scope,
7
- fixed,
8
- managed,
9
- seed,
10
- file,
11
- generate,
12
- when,
13
- whenFilesExist,
14
- ensureField,
15
- removeField,
16
- pruneKeysMatching,
17
- } from "../engine/api";
5
+ import { scope, fixed, managed, seed, file, generate, when, whenFilesExist } from "../engine/api";
18
6
  import { getActiveRunContext } from "../engine/builders";
19
7
  import { modelPackageJsonInitial, modelPackageJsonRules } from "./model-package-json";
20
8
  import { COLOCATED_TEST_GLOB } from "./shared/colocated-tests";
21
9
 
22
10
  export function modelRules(): void {
23
11
  scope("model", () => {
24
- // tsconfig is managed (not fixed) so node ambient types can be provided
25
- // CONDITIONALLY. The body still enforces the canonical shape —
26
- // `{extends, include}` with NO stray keys — and, ONLY when the model
27
- // carries co-located unit tests (`src/**/*.test.ts`), adds
28
- // `compilerOptions.types: ["node"]` so those tests type-check: provide
29
- // node types, do NOT exclude the tests. A test-less model is
30
- // canonicalised to bare `{extends, include}`
31
- // and stays a refresh fixpoint. The matching `@types/node` devDep is
32
- // wired by the package.json rule under the same predicate.
33
- managed("tsconfig.json", file("model/tsconfig.json"), () => {
34
- ensureField("extends", "@milaboratories/ts-configs/block/model");
35
- // Canonical default carries no compilerOptions; drop any legacy block's
36
- // before (conditionally) re-adding only `types`.
37
- removeField("compilerOptions");
38
- when(whenFilesExist(COLOCATED_TEST_GLOB), () => {
39
- ensureField("compilerOptions.types", ["node"]);
40
- });
41
- ensureField("include", ["src/**/*"]);
42
- // Strip anything else a legacy tsconfig carried (files, references, a
43
- // different extends layout) — keep only the canonical keys.
44
- pruneKeysMatching((k) => k !== "extends" && k !== "include" && k !== "compilerOptions");
45
- });
12
+ // Two static end states by co-located-test presence: a test-bearing model
13
+ // gets node ambient types (`.node.json`) so the tests type-check —
14
+ // `@types/node` is wired alongside by the package.json rule; a test-less
15
+ // model stays bare. `fixed` (engine-owned, whole-file overwrite) not
16
+ // `managed`, so refresh is idempotent by construction — no key-order drift.
17
+ when(
18
+ whenFilesExist(COLOCATED_TEST_GLOB),
19
+ () => fixed("tsconfig.json", file("model/tsconfig.node.json")),
20
+ () => fixed("tsconfig.json", file("model/tsconfig.json")),
21
+ );
46
22
  fixed(".oxlintrc.json", file("model/.oxlintrc.json"));
47
23
  fixed(".oxfmtrc.json", file("model/.oxfmtrc.json"));
48
24
 
@@ -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);
@@ -38,7 +38,7 @@ export function rootPackageJsonInitial(_ctx: RunContext): Record<string, unknown
38
38
  // refresh → format flow); kept for compatibility.
39
39
  "update-sdk": "block-tools structure refresh --update-deps-only",
40
40
  update:
41
- "block-tools structure refresh --update-deps-only && pnpm i && block-tools structure refresh && pnpm fmt",
41
+ "block-tools structure refresh --update-deps-only && pnpm i && block-tools structure refresh && pnpm i && pnpm fmt",
42
42
  },
43
43
  peerDependencies: {
44
44
  oxlint: "*",
@@ -79,12 +79,14 @@ export function rootPackageJsonRules(): void {
79
79
  // Deprecated in favour of `update`; kept for compatibility.
80
80
  ensureScript("update-sdk", "block-tools structure refresh --update-deps-only");
81
81
  // Full SDK-update flow: bump catalog (deps-only) → install → re-apply
82
- // structure against the freshly-pulled deps → format the result, wrapped
83
- // as one script. `pnpm fmt` (turbo run fmt) leaves the tree oxfmt-clean
84
- // after the structural rewrite so a follow-up `pnpm check` passes.
82
+ // structure against the freshly-pulled deps → install AGAIN (the structural
83
+ // pass can add new devDeps — e.g. ts-builder/oxlint/oxfmt on a first
84
+ // migration — that `pnpm fmt` then needs on PATH) → format. Wrapped as one
85
+ // script. `pnpm fmt` (turbo run fmt) leaves the tree oxfmt-clean after the
86
+ // structural rewrite so a follow-up `pnpm check` passes.
85
87
  ensureScript(
86
88
  "update",
87
- "block-tools structure refresh --update-deps-only && pnpm i && block-tools structure refresh && pnpm fmt",
89
+ "block-tools structure refresh --update-deps-only && pnpm i && block-tools structure refresh && pnpm i && pnpm fmt",
88
90
  );
89
91
 
90
92
  ensureDevDeps({
@@ -18,8 +18,8 @@
18
18
  // npm-latest for the SDK families) lives in the separate `onInitOrUpdate`
19
19
  // frame (`rootCatalogBumpRules`), which fires on init + update-deps only.
20
20
 
21
- import { ensureWorkspaceModulePaths, type RunContext } from "../engine/api";
22
- import { matchesBumpPattern } from "../engine/registry-client";
21
+ import { ensureWorkspaceModulePaths, ensureCatalogAbsent, type RunContext } from "../engine/api";
22
+ import { matchesBumpPattern, type DerivedCatalogPin } from "../engine/registry-client";
23
23
 
24
24
  /** SDK packages that live in the catalog (membership only — no versions).
25
25
  * Init fetches npm latest for each; refresh leaves them as the lockfile has
@@ -62,14 +62,23 @@ export const INFRA_CATALOG_FLOOR: Record<string, string> = {
62
62
  shx: "~0.4.0",
63
63
  "@changesets/cli": "~2.29.8",
64
64
  vitest: "~4.0.18",
65
- // The seeded ui depends on `vue` (catalog:). `@platforma-sdk/ui-vue` pins
66
- // vue as an EXACT regular dependency (not a peer), so the block must pin the
67
- // SAME exact version — a floated `~` lets the ui resolve a newer 3.5.x than
68
- // ui-vue's, yielding two vue instances and a SdkPluginV3-vs-Plugin type
69
- // clash in `main.ts`. Keep this in lockstep with ui-vue's vue pin.
70
- vue: "3.5.24",
71
65
  };
72
66
 
67
+ /** Catalog entries whose exact version is DERIVED from the version another
68
+ * package declares for them (see `pinCatalogToDependencyOf`). Resolved on
69
+ * init + update-deps and OVERWRITES the on-disk entry.
70
+ *
71
+ * `vue`: the seeded ui depends on `vue` (catalog:). `@platforma-sdk/ui-vue`
72
+ * pins vue as an EXACT regular dependency (not a peer), so the block must
73
+ * pin the SAME exact version — a floated `~` lets the ui resolve a newer
74
+ * 3.5.x than ui-vue's, yielding two vue instances and a SdkPluginV3-vs-Plugin
75
+ * type clash in `main.ts`. Deriving the pin from ui-vue's declared dep keeps
76
+ * the block in lockstep with whatever vue ui-vue's npm-latest pins, with no
77
+ * hand-maintained version here. */
78
+ export const DERIVED_CATALOG_PINS: readonly DerivedCatalogPin[] = [
79
+ { entry: "vue", of: "@platforma-sdk/ui-vue" },
80
+ ];
81
+
73
82
  /** Catalog name of the python runenv. Added to a block's catalog only when
74
83
  * the block carries a software module. Pinned to an EXACT version — updating
75
84
  * the python runenv is an explicit author decision, never automatic. */
@@ -100,4 +109,11 @@ export function rootPnpmWorkspaceInitial(ctx: RunContext): Record<string, unknow
100
109
 
101
110
  export function rootPnpmWorkspaceRules(): void {
102
111
  ensureWorkspaceModulePaths();
112
+ // vite/tsup/vue-tsc-era catalog entries: the canonical toolchain (ts-builder
113
+ // owns vue-tsc; ts-builder + rolldown replace vite/tsup) does not use them.
114
+ // Drop the orphaned catalog keys — paired with the per-package `removeDep`
115
+ // so a migrated block sheds both the dep and its catalog entry.
116
+ ensureCatalogAbsent("vite");
117
+ ensureCatalogAbsent("tsup");
118
+ ensureCatalogAbsent("vue-tsc");
103
119
  }
@@ -16,6 +16,7 @@ import {
16
16
  ensurePeerDeps,
17
17
  ensureWorkspaceScopeDeps,
18
18
  removeDep,
19
+ removeScript,
19
20
  enforceAlphabeticalOrder,
20
21
  enforceFieldOrder,
21
22
  when,
@@ -38,8 +39,6 @@ export function uiPackageJsonInitial(ctx: RunContext): Record<string, unknown> {
38
39
  watch: "ts-builder build --target block-ui --watch",
39
40
  build: "ts-builder build --target block-ui",
40
41
  check: "ts-builder check --target block-ui",
41
- // No `test`: the vitest `test` script is wired by the body rule ONLY
42
- // when co-located test files exist (a freshly-init'd ui has none).
43
42
  },
44
43
  dependencies: {
45
44
  "@platforma-sdk/ui-vue": "sdk:",
@@ -51,7 +50,6 @@ export function uiPackageJsonInitial(ctx: RunContext): Record<string, unknown> {
51
50
  devDependencies: {
52
51
  "@milaboratories/ts-builder": "sdk:",
53
52
  "@milaboratories/ts-configs": "sdk:",
54
- vitest: "catalog:",
55
53
  },
56
54
  peerDependencies: {
57
55
  typescript: "*",
@@ -77,9 +75,20 @@ export function uiPackageJsonRules(): void {
77
75
  ensureDevDeps({
78
76
  "@milaboratories/ts-builder": "sdk:",
79
77
  "@milaboratories/ts-configs": "sdk:",
80
- vitest: "catalog:",
81
78
  });
82
79
 
80
+ // `vue-tsc` is owned by `ts-builder` (which runs it internally for
81
+ // `--target block-ui`), so the ui must NOT declare its own. A direct
82
+ // `vue-tsc` dep is the vestigial vite/vue-tsc-era artefact: it resolves
83
+ // independently of the version ts-builder pins, which is how a block ended
84
+ // up type-checking under a different vue-tsc than the toolchain intends.
85
+ removeDep("vue-tsc");
86
+
87
+ // vite-era artefacts: the canonical ui builds + dev-serves via ts-builder,
88
+ // so a legacy `vite` dep and the `preview` (vite preview) script are dropped.
89
+ removeDep("vite");
90
+ removeScript("preview");
91
+
83
92
  // The ui builds with `--target block-ui` (types: []), so by default it
84
93
  // carries no browser/vitest/node ambient types — only the `typescript`
85
94
  // peer for IDE type resolution. Drop any stray `@types/node` a legacy
@@ -90,15 +99,27 @@ export function uiPackageJsonRules(): void {
90
99
  removeDep("@types/node");
91
100
 
92
101
  // ...but a ui WITH co-located unit tests (`src/**/*.test.ts`) needs the
93
- // vitest `test` script AND node ambient types: re-add `@types/node` as a
94
- // devDep so `types: ["node"]` in ui/tsconfig (wired under the same
95
- // predicate) resolves the tests' `node:*` imports. A test-less ui keeps
96
- // the lean default (no test script, no @types/node) and stays a fixpoint.
97
- // Runs AFTER removeDep so the dev dep is not stripped.
98
- when(whenFilesExist(COLOCATED_TEST_GLOB), () => {
99
- ensureScript("test", "vitest run --passWithNoTests");
100
- ensureDevDep("@types/node", "*");
101
- });
102
+ // vitest `test` script, the `vitest` devDep, AND node ambient types: re-add
103
+ // `@types/node` as a devDep so `types: ["node"]` in ui/tsconfig (wired under
104
+ // the same predicate) resolves the tests' `node:*` imports. A test-less ui
105
+ // keeps the lean default (no test script, no vitest, no @types/node) and
106
+ // stays a fixpoint. Runs AFTER removeDep so the dev deps are not stripped.
107
+ //
108
+ // Skipped entirely for sdk-internal (in-monorepo) blocks: they own their test
109
+ // wiring under the monorepo's shared infrastructure — see model-package-json.
110
+ when(
111
+ ({ ctx }) => !ctx.isSdkInternal,
112
+ () =>
113
+ when(
114
+ whenFilesExist(COLOCATED_TEST_GLOB),
115
+ () => {
116
+ ensureScript("test", "vitest run --passWithNoTests");
117
+ ensureDevDep("vitest", "catalog:");
118
+ ensureDevDep("@types/node", "*");
119
+ },
120
+ () => removeDep("vitest"),
121
+ ),
122
+ );
102
123
 
103
124
  enforceAlphabeticalOrder("dependencies");
104
125
  enforceAlphabeticalOrder("devDependencies");