@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
@@ -51,5 +51,5 @@ export type RunContext = {
51
51
  };
52
52
  export type { ContentForm, ManagedBody, RunMode, Structure, TriggerFn };
53
53
  export { defineStructure, scope, when, whenFilesExist, onUpdateDeps, onInitOrUpdate, fixed, managed, scaffold, seed, remove, rename, file, binaryFile, text, tpl, generate, blockVars, } from './builders';
54
- export { ensureField, removeField, requireField, ensureFieldEntries, ensureScript, removeScript, ensureDep, ensureDevDep, ensurePeerDep, ensureOptionalDep, removeDep, ensureDeps, ensureDevDeps, ensurePeerDeps, ensureOptionalDeps, ensureWorkspaceScopeDeps, ensureWorkspaceScopeDevDeps, ensureWorkspaceScopePeerDeps, pruneKeysMatching, pruneKeysMatchingAt, enforceFieldOrder, enforceFieldOrderAt, enforceAlphabeticalOrder, transformAt, ensureWorkspaceModulePaths, ensureCatalogVersion, pinCatalogTo, ensureCatalogLatest, ensureCatalogAbsent, ensureGitignoreEntries, removeGitignoreEntries, withManagedBody, withManagedYaml, withManagedLines, } from './content-rules';
54
+ export { ensureField, removeField, requireField, ensureFieldEntries, ensureScript, removeScript, ensureDep, ensureDevDep, ensurePeerDep, ensureOptionalDep, removeDep, ensureDeps, ensureDevDeps, ensurePeerDeps, ensureOptionalDeps, ensureWorkspaceScopeDeps, ensureWorkspaceScopeDevDeps, ensureWorkspaceScopePeerDeps, pruneKeysMatching, pruneKeysMatchingAt, enforceFieldOrder, enforceFieldOrderAt, enforceAlphabeticalOrder, transformAt, ensureWorkspaceModulePaths, ensureCatalogVersion, pinCatalogTo, pinCatalogToDependencyOf, ensureCatalogLatest, ensureCatalogAbsent, ensureGitignoreEntries, removeGitignoreEntries, withManagedBody, withManagedYaml, withManagedLines, } from './content-rules';
55
55
  export type { DepVersion } from './content-rules';
@@ -30,17 +30,17 @@ type InnerWhenHandler = (trigger: TriggerFn, body: () => void) => boolean;
30
30
  * `content-rules.ts` at load time to avoid a circular import). */
31
31
  export declare function registerInnerWhenHandler(h: InnerWhenHandler): void;
32
32
  /**
33
- * Conditionally run `body`. Two dispatch modes by active state:
34
- * - inside `defineStructure(...)` → push a `WhenFrame` into the tree;
35
- * trigger fires later at runner time per `FlatItem`.
36
- * - inside a `managed(...)` body → evaluate `trigger` immediately
37
- * against the runner-supplied `TriggerContext`; run/skip `body`
38
- * synchronously.
33
+ * Conditionally run `body`, with an optional `elseBody`. One `when` symbol for
34
+ * both layers — `dispatchWhen` picks the timing:
35
+ * - inside `defineStructure(...)` → register a `WhenFrame`; its trigger fires
36
+ * later, per module, at runner time.
37
+ * - inside a `managed(...)` body → evaluate the trigger against the live block
38
+ * now and run/skip synchronously.
39
39
  *
40
- * One `when` symbol for both layers — same user contract, dispatch
41
- * picks the matching execution timing.
40
+ * `else` is just the negated trigger dispatched the same way, so exactly one
41
+ * branch applies (in either layer).
42
42
  */
43
- export declare function when(trigger: TriggerFn, body: () => void): void;
43
+ export declare function when(trigger: TriggerFn, body: () => void, elseBody?: () => void): void;
44
44
  /** Trigger factory: true when at least one file in the leaf's module
45
45
  * matches `glob` (module-relative — see `TriggerContext.filesMatch`). Use
46
46
  * to gate a rule on the presence of co-located files, e.g. add a vitest
@@ -35,6 +35,9 @@ export type WithManagedYamlOpts = WithManagedOpts & {
35
35
  /** Sync accessor for prefetched latest versions. Required for
36
36
  * `ensureCatalogLatest` to take effect. */
37
37
  getLatestVersion?: (packageName: string) => string | undefined;
38
+ /** Sync accessor for prefetched derived catalog pins. Required for
39
+ * `pinCatalogToDependencyOf` to take effect. */
40
+ getDerivedDependencyPin?: (catalogEntry: string) => string | undefined;
38
41
  };
39
42
  /** Engine-internal: run `body` with `doc` as the active YAML state (for
40
43
  * `pnpm-workspace.yaml`); returns the mutated document. Exported for tests. */
@@ -140,6 +143,20 @@ export declare function pinCatalogTo(name: string, version: string): void;
140
143
  * and refreshes a present one, and on `init` it overwrites the
141
144
  * placeholder with the real latest. */
142
145
  export declare function ensureCatalogLatest(name: string): void;
146
+ /** Pin `catalog.<catalogEntry>` to the EXACT version that package `opts.of`
147
+ * declares for `catalogEntry` in its own manifest (read at npm latest, or at
148
+ * `opts.ofVersion` if given). The value is resolved by the runner's
149
+ * prefetched `getDerivedDependencyPin` accessor (the caller pre-resolves and
150
+ * validates — exact-version-only — at prefetch time; no network here).
151
+ * OVERWRITES like `pinCatalogTo` (not add-if-absent): a pre-existing loose
152
+ * entry (`vue: ^3.5.24`) is tightened to the exact derived version. No-op
153
+ * when no derived-pin lookup is available — default refresh/check pass none,
154
+ * leaving the entry exactly as the lockfile has it; the resolution leaves
155
+ * live in the `onInitOrUpdate` frame. */
156
+ export declare function pinCatalogToDependencyOf(catalogEntry: string, _opts: {
157
+ of: string;
158
+ ofVersion?: string;
159
+ }): void;
143
160
  /** Remove `catalog.<name>` if present; no-op when absent. */
144
161
  export declare function ensureCatalogAbsent(name: string): void;
145
162
  /** Append each `entry` if not already present (comment-aware equality). */
@@ -1,5 +1,10 @@
1
1
  export interface RegistryClient {
2
2
  getLatestVersion(packageName: string): Promise<string>;
3
+ /** Read the version `packageName@version` declares for `depName` in its
4
+ * published manifest. `version` may be "latest" (resolved via the
5
+ * dist-tags). Looks in `dependencies` first, then `peerDependencies`.
6
+ * Returns `undefined` when the dep is declared in neither section. */
7
+ getDeclaredDependency(packageName: string, version: string, depName: string): Promise<string | undefined>;
3
8
  }
4
9
  /** Parse the HTTP Retry-After header (RFC 9110): either a delay in
5
10
  * seconds or an absolute HTTP-date. Returns the delay in ms, or null
@@ -7,8 +12,10 @@ export interface RegistryClient {
7
12
  export declare function parseRetryAfter(header: string | null): number | null;
8
13
  /** Live npm registry client. Hits the network on each call. */
9
14
  export declare function createRealRegistryClient(): RegistryClient;
10
- /** In-memory client backed by a fixed `name → version` map. */
11
- export declare function createMockRegistryClient(versions: Record<string, string>): RegistryClient;
15
+ /** In-memory client backed by a fixed `name → version` map. The optional
16
+ * `manifests` map (`pkg@version` → declared deps) backs
17
+ * `getDeclaredDependency`; "latest" keys resolve via the `versions` map. */
18
+ export declare function createMockRegistryClient(versions: Record<string, string>, manifests?: Record<string, Record<string, string>>): RegistryClient;
12
19
  /** Resolve a batch of `name → latest` entries in parallel. */
13
20
  export declare function prefetchLatestVersions(client: RegistryClient, packageNames: readonly string[]): Promise<Map<string, string>>;
14
21
  /** Build a sync lookup function (the shape `withManagedYaml` consumes)
@@ -32,3 +39,24 @@ export declare function matchesBumpPattern(name: string): boolean;
32
39
  * (callers that require the network — `init` — let it propagate).
33
40
  * `client` defaults to the live npm client; tests inject a mock. */
34
41
  export declare function buildRegistryLookupForNames(names: readonly string[], client?: RegistryClient): Promise<(packageName: string) => string | undefined>;
42
+ /** A catalog entry whose version is DERIVED from the version another
43
+ * package declares for it. `entry` is the catalog key (== the dep name in
44
+ * the source manifest); `of` is the package whose manifest is read; the
45
+ * optional `ofVersion` pins which release of `of` to read (default: npm
46
+ * latest, the same release the catalog bump pins `of` to). Consumed by
47
+ * `pinCatalogToDependencyOf`. */
48
+ export type DerivedCatalogPin = {
49
+ entry: string;
50
+ of: string;
51
+ ofVersion?: string;
52
+ };
53
+ /** Prefetch every derived pin and build the sync lookup the runner passes
54
+ * to `pinCatalogToDependencyOf` (`entry → exact version`). Reads
55
+ * `pin.of`'s manifest (at `pin.ofVersion`, default npm latest) and the
56
+ * version it declares for `pin.entry`. Policy (b): THROWS at prefetch time
57
+ * if the source declares the dep as a non-exact (ranged) version or does
58
+ * not declare it at all — a derived pin must resolve to an exact version
59
+ * or it is a configuration error. Empty list → a lookup that resolves
60
+ * nothing (no network). A registry failure REJECTS. `client` defaults to
61
+ * the live npm client; tests inject a mock. */
62
+ export declare function buildDerivedPinLookupForPins(pins: readonly DerivedCatalogPin[], client?: RegistryClient): Promise<(entry: string) => string | undefined>;
@@ -32,6 +32,12 @@ export type RunOptions = {
32
32
  * and passes the resulting sync map here; unit tests pass a mock.
33
33
  * Absent → `ensureCatalogLatest` is a no-op (default refresh). */
34
34
  registryLookup?: (packageName: string) => string | undefined;
35
+ /** Sync accessor for prefetched derived catalog pins
36
+ * (`catalogEntry → exact version`), consumed by `pinCatalogToDependencyOf`
37
+ * inside `onInitOrUpdate` managed bodies. The CLI / init constructor
38
+ * prefetches + validates these before the run (network). Absent →
39
+ * `pinCatalogToDependencyOf` is a no-op (default refresh). */
40
+ derivedPinLookup?: (catalogEntry: string) => string | undefined;
35
41
  };
36
42
  export declare class RecheckError extends Error {
37
43
  readonly failing: Change;
@@ -1,4 +1,5 @@
1
1
  import { RunContext } from '../engine/api';
2
+ import { DerivedCatalogPin } from '../engine/registry-client';
2
3
  /** SDK packages that live in the catalog (membership only — no versions).
3
4
  * Init fetches npm latest for each; refresh leaves them as the lockfile has
4
5
  * them. */
@@ -6,6 +7,18 @@ export declare const SDK_CATALOG_PACKAGES: readonly string[];
6
7
  /** Infra / tooling — a `~`-floored curated set. Not exact-pinned on refresh
7
8
  * and not bumped by update-deps. */
8
9
  export declare const INFRA_CATALOG_FLOOR: Record<string, string>;
10
+ /** Catalog entries whose exact version is DERIVED from the version another
11
+ * package declares for them (see `pinCatalogToDependencyOf`). Resolved on
12
+ * init + update-deps and OVERWRITES the on-disk entry.
13
+ *
14
+ * `vue`: the seeded ui depends on `vue` (catalog:). `@platforma-sdk/ui-vue`
15
+ * pins vue as an EXACT regular dependency (not a peer), so the block must
16
+ * pin the SAME exact version — a floated `~` lets the ui resolve a newer
17
+ * 3.5.x than ui-vue's, yielding two vue instances and a SdkPluginV3-vs-Plugin
18
+ * type clash in `main.ts`. Deriving the pin from ui-vue's declared dep keeps
19
+ * the block in lockstep with whatever vue ui-vue's npm-latest pins, with no
20
+ * hand-maintained version here. */
21
+ export declare const DERIVED_CATALOG_PINS: readonly DerivedCatalogPin[];
9
22
  /** Catalog name of the python runenv. Added to a block's catalog only when
10
23
  * the block carries a software module. Pinned to an EXACT version — updating
11
24
  * the python runenv is an explicit author decision, never automatic. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/block-tools",
3
- "version": "2.10.16",
3
+ "version": "2.10.18",
4
4
  "description": "Utility to manipulate Platforma Blocks and Block Registry",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -35,11 +35,11 @@
35
35
  "zod": "~3.25.76",
36
36
  "@milaboratories/pl-http": "1.2.4",
37
37
  "@milaboratories/pl-model-middle-layer": "1.30.6",
38
- "@milaboratories/pl-model-backend": "1.4.7",
39
- "@milaboratories/resolve-helper": "1.1.3",
40
38
  "@milaboratories/pl-model-common": "1.46.1",
41
- "@milaboratories/ts-helpers-oclif": "1.1.42",
39
+ "@milaboratories/resolve-helper": "1.1.3",
40
+ "@milaboratories/pl-model-backend": "1.4.7",
42
41
  "@milaboratories/ts-helpers": "1.8.3",
42
+ "@milaboratories/ts-helpers-oclif": "1.1.42",
43
43
  "@platforma-sdk/blocks-deps-updater": "2.2.0"
44
44
  },
45
45
  "devDependencies": {
@@ -57,8 +57,8 @@
57
57
  "vite-plugin-dts": "^4.5.3",
58
58
  "vitest": "^4.1.3",
59
59
  "@milaboratories/oclif-index": "1.1.1",
60
- "@milaboratories/ts-builder": "1.5.0",
61
60
  "@milaboratories/ts-configs": "1.2.3",
61
+ "@milaboratories/ts-builder": "1.5.2",
62
62
  "@milaboratories/build-configs": "2.0.0"
63
63
  },
64
64
  "oclif": {
@@ -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": "*",
@@ -0,0 +1,94 @@
1
+ // Regression: a test-bearing model/ui scope migrating from a LEGACY tsconfig
2
+ // must converge in a single pass.
3
+ //
4
+ // History: the tsconfig used to be a `managed` file whose body did
5
+ // `removeField("compilerOptions")`, conditionally re-added
6
+ // `compilerOptions.types: ["node"]`, then `ensureField("include", …)`. On a
7
+ // legacy tsconfig with no `include`, pass 1 appended `include` AFTER the
8
+ // re-added `compilerOptions` → `{extends, compilerOptions, include}`, while the
9
+ // body's fixpoint was `{extends, include, compilerOptions}`. The two differed
10
+ // only in key order, so the engine's post-run recheck saw a phantom diff and
11
+ // `refresh` aborted with a non-idempotency RecheckError on EVERY test-bearing
12
+ // block migrating off a legacy (vite-era) tsconfig.
13
+ //
14
+ // Now the tsconfig is a `fixed` file with two static end states selected by a
15
+ // `when`/else on co-located-test presence (`tsconfig.node.json` vs
16
+ // `tsconfig.json`). There is no imperative body and no insertion-order side
17
+ // effect, so single-pass idempotency holds by construction. This test guards
18
+ // that property.
19
+
20
+ import { describe, test, expect } from "vitest";
21
+ import { simulateInit, defaultTemplateProvider } from "../engine/testing";
22
+ import { run as engineRun } from "../engine/runner";
23
+ import { discoverRunContext } from "../engine/discovery-fs";
24
+ import { STRUCTURE } from "../structure-definition";
25
+ import { SDK_CATALOG_PACKAGES } from "../rules/root-pnpm-workspace";
26
+ import type { BlockVars } from "../engine/api";
27
+
28
+ const VARS: BlockVars = {
29
+ facadeName: "@platforma-open/test-org.demo",
30
+ baseName: "test-org.demo",
31
+ npmOrg: "@platforma-open",
32
+ orgScope: "test-org",
33
+ shortName: "demo",
34
+ };
35
+
36
+ const mockLookup = (name: string): string | undefined =>
37
+ SDK_CATALOG_PACKAGES.includes(name) ? "9.9.9" : undefined;
38
+
39
+ // `refresh` runs the full engine INCLUDING the post-run recheck (via
40
+ // `rediscover`); a non-idempotent rule set makes this throw.
41
+ function refresh(fs: ReturnType<typeof simulateInit>["fs"]) {
42
+ const ctx = discoverRunContext({ fs, isSdkInternal: false });
43
+ engineRun(STRUCTURE, fs, ctx, {
44
+ templates: defaultTemplateProvider(),
45
+ rediscover: () => discoverRunContext({ fs, isSdkInternal: false, dryRun: true }),
46
+ });
47
+ }
48
+
49
+ const CANONICAL_ORDER = ["extends", "compilerOptions", "include"];
50
+
51
+ describe("tsconfig first-pass idempotency on legacy (no-include) tsconfig", () => {
52
+ test("model + ui co-located tests, legacy tsconfig without `include` → refresh converges in one pass", () => {
53
+ const { fs } = simulateInit({ vars: VARS, registryLookup: mockLookup });
54
+
55
+ // Co-located unit tests in both scopes -> the conditional node-types re-add fires.
56
+ fs.write("model/src/m.test.ts", `import { test } from "vitest";\ntest("x", () => {});\n`);
57
+ fs.write("ui/src/u.test.ts", `import { test } from "vitest";\ntest("x", () => {});\n`);
58
+
59
+ // Legacy tsconfigs as a vite-era block carried them: a `compilerOptions`
60
+ // block and NO top-level `include`. This is what made pass 1 append
61
+ // `include` after the re-added `compilerOptions`.
62
+ fs.write(
63
+ "model/tsconfig.json",
64
+ JSON.stringify({
65
+ extends: "@milaboratories/ts-configs/block/model",
66
+ compilerOptions: { strict: true },
67
+ }),
68
+ );
69
+ fs.write(
70
+ "ui/tsconfig.json",
71
+ JSON.stringify({
72
+ extends: "@milaboratories/ts-configs/block/ui",
73
+ compilerOptions: { strict: true },
74
+ }),
75
+ );
76
+
77
+ // Without the fix this throws RecheckError (rule set not idempotent).
78
+ expect(() => refresh(fs)).not.toThrow();
79
+
80
+ for (const scope of ["model", "ui"]) {
81
+ const ts = JSON.parse(fs.read(`${scope}/tsconfig.json`)) as {
82
+ extends: string;
83
+ include: string[];
84
+ compilerOptions: { types: string[] };
85
+ };
86
+ // Canonical, deterministic key order.
87
+ expect(Object.keys(ts)).toEqual(CANONICAL_ORDER);
88
+ expect(ts.extends).toBe(`@milaboratories/ts-configs/block/${scope}`);
89
+ expect(ts.include).toEqual(["src/**/*"]);
90
+ // node types wired (co-located test present); legacy `strict` pruned.
91
+ expect(ts.compilerOptions).toEqual({ types: ["node"] });
92
+ }
93
+ });
94
+ });
@@ -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
+ });
@@ -222,6 +222,36 @@ describe("when() — inner-mode dispatch inside managed bodies", () => {
222
222
  ).toThrow(/outside defineStructure.*outside any managed/);
223
223
  });
224
224
 
225
+ test("else branch: predicate=true runs body, skips else", () => {
226
+ const out = withManagedBody(
227
+ {} as JsonObject,
228
+ () => {
229
+ when(
230
+ ({ pathExists }) => pathExists("tests/"),
231
+ () => ensureField("branch", "if"),
232
+ () => ensureField("branch", "else"),
233
+ );
234
+ },
235
+ { triggerContext: makeTctx({ paths: new Set(["tests/"]) }) },
236
+ );
237
+ expect(out.branch).toBe("if");
238
+ });
239
+
240
+ test("else branch: predicate=false runs else, skips body", () => {
241
+ const out = withManagedBody(
242
+ {} as JsonObject,
243
+ () => {
244
+ when(
245
+ ({ pathExists }) => pathExists("tests/"),
246
+ () => ensureField("branch", "if"),
247
+ () => ensureField("branch", "else"),
248
+ );
249
+ },
250
+ { triggerContext: makeTctx({ paths: new Set() }) },
251
+ );
252
+ expect(out.branch).toBe("else");
253
+ });
254
+
225
255
  test("idempotent — double-run yields the same parsed state", () => {
226
256
  const body = () => {
227
257
  ensureField("type", "module");
@@ -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
@@ -193,34 +193,49 @@ export function registerInnerWhenHandler(h: InnerWhenHandler): void {
193
193
  }
194
194
 
195
195
  /**
196
- * Conditionally run `body`. Two dispatch modes by active state:
197
- * - inside `defineStructure(...)` → push a `WhenFrame` into the tree;
198
- * trigger fires later at runner time per `FlatItem`.
199
- * - inside a `managed(...)` body → evaluate `trigger` immediately
200
- * against the runner-supplied `TriggerContext`; run/skip `body`
201
- * synchronously.
196
+ * Conditionally run `body`, with an optional `elseBody`. One `when` symbol for
197
+ * both layers — `dispatchWhen` picks the timing:
198
+ * - inside `defineStructure(...)` → register a `WhenFrame`; its trigger fires
199
+ * later, per module, at runner time.
200
+ * - inside a `managed(...)` body → evaluate the trigger against the live block
201
+ * now and run/skip synchronously.
202
202
  *
203
- * One `when` symbol for both layers — same user contract, dispatch
204
- * picks the matching execution timing.
203
+ * `else` is just the negated trigger dispatched the same way, so exactly one
204
+ * branch applies (in either layer).
205
205
  */
206
- export function when(trigger: TriggerFn, body: () => void): void {
206
+ export function when(trigger: TriggerFn, body: () => void, elseBody?: () => void): void {
207
+ dispatchWhen(trigger, body);
208
+ if (elseBody) dispatchWhen((tctx) => !trigger(tctx), elseBody);
209
+ }
210
+
211
+ /** Single-branch dispatch shared by both `when` branches. In a managed body the
212
+ * handler runs `body` iff its trigger holds and returns whether a managed-body
213
+ * context existed; no context — and not tree-build either — means `when()` was
214
+ * called outside both layers, a misuse. */
215
+ function dispatchWhen(trigger: TriggerFn, body: () => void): void {
207
216
  if (activeTree) {
208
- const t = activeTree;
209
- const frame: WhenFrame = { kind: "when", trigger, children: [] };
210
- t.stack[t.stack.length - 1]!.push(frame);
211
- t.stack.push(frame.children);
212
- try {
213
- body();
214
- } finally {
215
- t.stack.pop();
216
- }
217
+ pushWhenFrame(trigger, body);
217
218
  return;
218
219
  }
219
- if (innerWhenHandler && innerWhenHandler(trigger, body)) return;
220
- throw new Error(
221
- "when() called outside defineStructure() and outside any managed(...) body. " +
222
- "Place this call inside one of the two.",
223
- );
220
+ const handled = innerWhenHandler?.(trigger, body) ?? false;
221
+ if (!handled) {
222
+ throw new Error(
223
+ "when() called outside defineStructure() and outside any managed(...) body. " +
224
+ "Place this call inside one of the two.",
225
+ );
226
+ }
227
+ }
228
+
229
+ function pushWhenFrame(trigger: TriggerFn, body: () => void): void {
230
+ const t = activeTree!;
231
+ const frame: WhenFrame = { kind: "when", trigger, children: [] };
232
+ t.stack[t.stack.length - 1]!.push(frame);
233
+ t.stack.push(frame.children);
234
+ try {
235
+ body();
236
+ } finally {
237
+ t.stack.pop();
238
+ }
224
239
  }
225
240
 
226
241
  /** Trigger factory: true when at least one file in the leaf's module
@@ -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");