@platforma-sdk/block-tools 2.10.15 → 2.10.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/block-tools",
3
- "version": "2.10.15",
3
+ "version": "2.10.17",
4
4
  "description": "Utility to manipulate Platforma Blocks and Block Registry",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -33,12 +33,12 @@
33
33
  "undici": "~7.16.0",
34
34
  "yaml": "^2.8.0",
35
35
  "zod": "~3.25.76",
36
- "@milaboratories/pl-http": "1.2.4",
37
36
  "@milaboratories/pl-model-common": "1.46.1",
38
37
  "@milaboratories/pl-model-backend": "1.4.7",
39
- "@milaboratories/pl-model-middle-layer": "1.30.5",
40
- "@milaboratories/ts-helpers": "1.8.3",
41
38
  "@milaboratories/resolve-helper": "1.1.3",
39
+ "@milaboratories/pl-model-middle-layer": "1.30.6",
40
+ "@milaboratories/ts-helpers": "1.8.3",
41
+ "@milaboratories/pl-http": "1.2.4",
42
42
  "@milaboratories/ts-helpers-oclif": "1.1.42",
43
43
  "@platforma-sdk/blocks-deps-updater": "2.2.0"
44
44
  },
@@ -56,10 +56,10 @@
56
56
  "vite": "^8.0.6",
57
57
  "vite-plugin-dts": "^4.5.3",
58
58
  "vitest": "^4.1.3",
59
+ "@milaboratories/build-configs": "2.0.0",
59
60
  "@milaboratories/oclif-index": "1.1.1",
60
61
  "@milaboratories/ts-configs": "1.2.3",
61
- "@milaboratories/build-configs": "2.0.0",
62
- "@milaboratories/ts-builder": "1.5.0"
62
+ "@milaboratories/ts-builder": "1.5.1"
63
63
  },
64
64
  "oclif": {
65
65
  "bin": "block-tools",
@@ -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
+ });
@@ -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");
@@ -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
@@ -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
 
@@ -80,6 +80,13 @@ export function uiPackageJsonRules(): void {
80
80
  vitest: "catalog:",
81
81
  });
82
82
 
83
+ // `vue-tsc` is owned by `ts-builder` (which runs it internally for
84
+ // `--target block-ui`), so the ui must NOT declare its own. A direct
85
+ // `vue-tsc` dep is the vestigial vite/vue-tsc-era artefact: it resolves
86
+ // independently of the version ts-builder pins, which is how a block ended
87
+ // up type-checking under a different vue-tsc than the toolchain intends.
88
+ removeDep("vue-tsc");
89
+
83
90
  // The ui builds with `--target block-ui` (types: []), so by default it
84
91
  // carries no browser/vitest/node ambient types — only the `typescript`
85
92
  // peer for IDE type resolution. Drop any stray `@types/node` a legacy
@@ -13,9 +13,6 @@ import {
13
13
  blockVars,
14
14
  when,
15
15
  whenFilesExist,
16
- ensureField,
17
- removeField,
18
- pruneKeysMatching,
19
16
  } from "../engine/api";
20
17
  import { getActiveRunContext } from "../engine/builders";
21
18
  import { uiPackageJsonInitial, uiPackageJsonRules } from "./ui-package-json";
@@ -23,25 +20,16 @@ import { COLOCATED_TEST_GLOB } from "./shared/colocated-tests";
23
20
 
24
21
  export function uiRules(): void {
25
22
  scope("ui", () => {
26
- // tsconfig is managed (not fixed) so node ambient types can be provided
27
- // CONDITIONALLY. The body still enforces the canonical shape —
28
- // `{extends, include}` with NO stray keys — and, ONLY when the ui
29
- // carries co-located unit tests (`src/**/*.test.ts`), adds
30
- // `compilerOptions.types: ["node"]` so tests importing `node:*`
31
- // type-check under vue-tsc: provide node types, do NOT exclude the
32
- // tests. A test-less ui is canonicalised to
33
- // bare `{extends, include}` and stays a refresh fixpoint. The matching
34
- // `@types/node` devDep is wired by the package.json rule under the same
35
- // predicate.
36
- managed("tsconfig.json", file("ui/tsconfig.json"), () => {
37
- ensureField("extends", "@milaboratories/ts-configs/block/ui");
38
- removeField("compilerOptions");
39
- when(whenFilesExist(COLOCATED_TEST_GLOB), () => {
40
- ensureField("compilerOptions.types", ["node"]);
41
- });
42
- ensureField("include", ["src/**/*"]);
43
- pruneKeysMatching((k) => k !== "extends" && k !== "include" && k !== "compilerOptions");
44
- });
23
+ // Two static end states by co-located-test presence: a test-bearing ui gets
24
+ // node ambient types (`.node.json`) so tests' `node:*` imports type-check
25
+ // under vue-tsc — `@types/node` is wired alongside by the package.json rule;
26
+ // a test-less ui stays bare. `fixed` (engine-owned, whole-file overwrite)
27
+ // not `managed`, so refresh is idempotent by construction — no key-order drift.
28
+ when(
29
+ whenFilesExist(COLOCATED_TEST_GLOB),
30
+ () => fixed("tsconfig.json", file("ui/tsconfig.node.json")),
31
+ () => fixed("tsconfig.json", file("ui/tsconfig.json")),
32
+ );
45
33
  fixed(".oxlintrc.json", file("ui/.oxlintrc.json"));
46
34
  // Block-local oxfmt config — the ui is oxfmt-checked (`ts-builder check
47
35
  // --target block-ui`).
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "@milaboratories/ts-configs/block/model",
3
+ "compilerOptions": {
4
+ "types": ["node"]
5
+ },
6
+ "include": ["src/**/*"]
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "@milaboratories/ts-configs/block/ui",
3
+ "compilerOptions": {
4
+ "types": ["node"]
5
+ },
6
+ "include": ["src/**/*"]
7
+ }