@platforma-sdk/block-tools 2.10.17 → 2.10.19

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 (36) hide show
  1. package/dist/cli.js +13 -13
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +674 -615
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/structure/engine/api.d.ts +1 -1
  6. package/dist/structure/engine/content-rules.d.ts +17 -0
  7. package/dist/structure/engine/registry-client.d.ts +30 -2
  8. package/dist/structure/engine/runner.d.ts +6 -0
  9. package/dist/structure/rules/root-pnpm-workspace.d.ts +13 -0
  10. package/dist/structure/rules/shared/retired-deps.d.ts +7 -0
  11. package/dist/v2/registry/schema_public.d.ts +6177 -6177
  12. package/package.json +8 -8
  13. package/src/structure/CLAUDE.md +28 -0
  14. package/src/structure/__tests__/model-package-json.snapshot.test.ts +1 -2
  15. package/src/structure/cli/run-structure.ts +12 -2
  16. package/src/structure/engine/__tests__/content-rules/pin-catalog-to-dependency-of.test.ts +65 -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/content-rules.ts +29 -0
  20. package/src/structure/engine/registry-client.ts +116 -2
  21. package/src/structure/engine/runner.ts +18 -2
  22. package/src/structure/init-block-constructor.ts +16 -3
  23. package/src/structure/rules/migrations.ts +15 -16
  24. package/src/structure/rules/model-package-json.ts +34 -16
  25. package/src/structure/rules/root-catalog-bump.ts +12 -1
  26. package/src/structure/rules/root-package-json.ts +15 -11
  27. package/src/structure/rules/root-pnpm-workspace.ts +21 -8
  28. package/src/structure/rules/root.ts +0 -1
  29. package/src/structure/rules/shared/retired-deps.ts +42 -0
  30. package/src/structure/rules/test-package-json.ts +12 -9
  31. package/src/structure/rules/test.ts +4 -0
  32. package/src/structure/rules/ui-package-json.ts +33 -25
  33. package/src/structure/rules/workflow-package-json.ts +32 -14
  34. package/src/structure/rules/workflow.ts +32 -8
  35. package/src/structure/templates/static/test/.oxfmtrc.json +4 -0
  36. package/src/structure/templates/static/test/.oxlintrc.json +3 -0
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.19",
4
4
  "description": "Utility to manipulate Platforma Blocks and Block Registry",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -33,14 +33,14 @@
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",
36
37
  "@milaboratories/pl-model-common": "1.46.1",
37
- "@milaboratories/pl-model-backend": "1.4.7",
38
- "@milaboratories/resolve-helper": "1.1.3",
39
38
  "@milaboratories/pl-model-middle-layer": "1.30.6",
40
- "@milaboratories/ts-helpers": "1.8.3",
41
- "@milaboratories/pl-http": "1.2.4",
39
+ "@milaboratories/resolve-helper": "1.1.3",
42
40
  "@milaboratories/ts-helpers-oclif": "1.1.42",
43
- "@platforma-sdk/blocks-deps-updater": "2.2.0"
41
+ "@platforma-sdk/blocks-deps-updater": "2.2.0",
42
+ "@milaboratories/ts-helpers": "1.8.3",
43
+ "@milaboratories/pl-model-backend": "1.4.7"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@rollup/plugin-node-resolve": "^16.0.1",
@@ -58,8 +58,8 @@
58
58
  "vitest": "^4.1.3",
59
59
  "@milaboratories/build-configs": "2.0.0",
60
60
  "@milaboratories/oclif-index": "1.1.1",
61
- "@milaboratories/ts-configs": "1.2.3",
62
- "@milaboratories/ts-builder": "1.5.1"
61
+ "@milaboratories/ts-builder": "1.5.2",
62
+ "@milaboratories/ts-configs": "1.2.3"
63
63
  },
64
64
  "oclif": {
65
65
  "bin": "block-tools",
@@ -0,0 +1,28 @@
1
+ # structurer — mental model
2
+
3
+ A declarative reconciler for a block's scaffolding. `rules/` DECLARE the
4
+ canonical end-state of every non-source file in a block (package.json, configs,
5
+ tsconfig, scripts, catalog); `engine/` reconciles any block toward that state.
6
+ To change what "canonical" means, edit `rules/`; the engine is block-agnostic.
7
+
8
+ ## Boundaries
9
+
10
+ - `engine/` — generic reconciliation primitives, module discovery, IR. Knows nothing about blocks.
11
+ - `rules/` — the only home of block-specific layout knowledge. `templates/` holds the verbatim file bodies the rules reference.
12
+ - Owns: the shape of a block's scaffolding. Is NOT a build tool, and NOT the author of a block's source — seeded/scaffolded files become author-owned once written.
13
+
14
+ ## Reading posture
15
+
16
+ - Each `rules/*` file pairs a generator (init: full initial content) with a body (refresh: drift-correcting assertions). Read as "what the file should be" + "how to nudge an existing file there", not as sequential logic.
17
+ - The primitive choice IS the design: `fixed` (overwrite verbatim, engine-owned), `managed` (reconcile named fields, author keeps the rest), `scaffold` (write-once default, author may tune), `seed` (written once at init, author owns forever), `remove` (one-way). Wrong primitive is the main design error — `fixed` on an author-tunable file clobbers their work; `seed` on engine-owned content lets drift accumulate.
18
+
19
+ ## Invariants
20
+
21
+ - Fixpoint: `refresh` on an already-canonical block makes zero changes. Every rule must converge.
22
+ - Generators emit oxfmt-clean output (the `enforce*` calls mirror oxfmt's order), so build→check passes with no prior `fmt`.
23
+ - sdk-internal blocks (`etc/blocks/*`) skip root-scope and standalone test wiring — the monorepo owns that infra; the structurer must neither impose nor strip it.
24
+
25
+ ## Gotchas
26
+
27
+ - A rule change also changes the canonical for `etc/blocks/*`: refresh them in the SAME PR (`pnpm run check-blocks`) or CI fails.
28
+ - Dep/peer changes need a `pnpm i` lockfile sync — pnpm records peers and per-importer deps in pnpm-lock.yaml; frozen-install CI fails otherwise.
@@ -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)"
@@ -23,40 +23,39 @@ export function testFrameworkMigration(): void {
23
23
  // Unconditional legacy-cleanup rules. Each is a no-op on an
24
24
  // already-canonical block (remove on an absent path, removeScript /
25
25
  // removeDep on an absent key) and idempotent, so they sit at top level
26
- // with no `when` gate. The trailing comment on each line names the real
27
- // block(s) that carry the artefact being cleaned.
26
+ // with no `when` gate.
28
27
  //
29
28
  // Root-scope cleanup is skipped for `--sdk-internal` blocks (no root
30
29
  // module in that mode), so it never touches in-monorepo blocks.
31
30
  export function legacyCleanup(): void {
32
- // eslint → oxlint: the canonical layout ships `.oxlintrc.json` /
33
- // `.oxfmtrc.json`; flat eslint configs are retired.
31
+ // Retired per-scope config files: flat eslint configs and vite-era build /
32
+ // split-tsconfig files, replaced by the oxlint/oxfmt + ts-builder layout.
34
33
  scope("model", () => {
35
- remove("eslint.config.mjs"); // samples-and-data, mixcr-clonotyping, clonotype-clustering, antibody-sequence-liabilities
36
- remove("vite.config.mts"); // samples-and-data (vite build → ts-builder)
34
+ remove("eslint.config.mjs");
35
+ remove("vite.config.mts");
37
36
  });
38
37
  scope("ui", () => {
39
- remove("eslint.config.mjs"); // samples-and-data, mixcr-clonotyping, clonotype-clustering, antibody-sequence-liabilities
40
- remove("vite.config.ts"); // samples-and-data (vite build → ts-builder)
41
- remove("tsconfig.app.json"); // samples-and-data (vite-era split tsconfig)
42
- remove("tsconfig.node.json"); // samples-and-data (vite-era split tsconfig)
38
+ remove("eslint.config.mjs");
39
+ remove("vite.config.ts");
40
+ remove("tsconfig.app.json");
41
+ remove("tsconfig.node.json");
43
42
  });
44
43
  scope("test", () => {
45
- remove("eslint.config.mjs"); // all 5 experiment blocks
44
+ remove("eslint.config.mjs");
46
45
  });
47
46
 
48
47
  // Root-scope cleanup. The second `managed("package.json")` body composes
49
48
  // after rootRules.
50
49
  scope("root", () => {
51
- remove(".prettierrc"); // samples-and-data, sequence-properties (prettier → oxfmt)
50
+ remove(".prettierrc");
52
51
  managed(
53
52
  "package.json",
54
53
  generate(() => rootPackageJsonInitial(getActiveRunContext())),
55
54
  () => {
56
- // `pretty` (prettier) is superseded by the canonical `fmt` (oxfmt);
57
- // the standalone deps-updater was merged into block-tools.
58
- removeScript("pretty"); // samples-and-data
59
- removeDep("@platforma-sdk/blocks-deps-updater"); // samples-and-data
55
+ // `pretty` (prettier) is superseded by `fmt` (oxfmt); the standalone
56
+ // deps-updater is now part of block-tools.
57
+ removeScript("pretty");
58
+ removeDep("@platforma-sdk/blocks-deps-updater");
60
59
  },
61
60
  );
62
61
  });