@platforma-sdk/block-tools 2.10.19 → 2.11.1

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 (33) hide show
  1. package/dist/cli.js +11 -11
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +362 -352
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/structure/engine/builders.d.ts +7 -6
  6. package/dist/structure/engine/content-rules.d.ts +4 -4
  7. package/dist/structure/engine/ir.d.ts +11 -6
  8. package/dist/structure/rules/block-package-json.d.ts +1 -1
  9. package/dist/structure/rules/root-ci.d.ts +1 -0
  10. package/dist/v2/registry/schema_public.d.ts +150 -150
  11. package/package.json +6 -6
  12. package/src/structure/CLAUDE.md +2 -0
  13. package/src/structure/__tests__/discovery-shortname.test.ts +50 -0
  14. package/src/structure/__tests__/root-ci-workflows.test.ts +68 -0
  15. package/src/structure/engine/builders.ts +7 -6
  16. package/src/structure/engine/content-rules.ts +7 -7
  17. package/src/structure/engine/discovery-fs.ts +9 -1
  18. package/src/structure/engine/ir.ts +11 -6
  19. package/src/structure/engine/runner.ts +14 -14
  20. package/src/structure/rules/block-package-json.ts +2 -3
  21. package/src/structure/rules/block.ts +3 -4
  22. package/src/structure/rules/migrations.ts +1 -2
  23. package/src/structure/rules/model.ts +1 -2
  24. package/src/structure/rules/root-catalog-bump.ts +3 -4
  25. package/src/structure/rules/root-ci.ts +55 -0
  26. package/src/structure/rules/root.ts +2 -3
  27. package/src/structure/rules/software.ts +1 -2
  28. package/src/structure/rules/test.ts +1 -2
  29. package/src/structure/rules/ui.ts +3 -5
  30. package/src/structure/rules/workflow.ts +3 -5
  31. package/src/structure/structure-definition.ts +2 -0
  32. package/src/structure/templates/text/workflows/build.tpl.yaml +67 -0
  33. package/src/structure/templates/text/workflows/mark-stable.tpl.yaml +34 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/block-tools",
3
- "version": "2.10.19",
3
+ "version": "2.11.1",
4
4
  "description": "Utility to manipulate Platforma Blocks and Block Registry",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -34,13 +34,13 @@
34
34
  "yaml": "^2.8.0",
35
35
  "zod": "~3.25.76",
36
36
  "@milaboratories/pl-http": "1.2.4",
37
- "@milaboratories/pl-model-common": "1.46.1",
38
- "@milaboratories/pl-model-middle-layer": "1.30.6",
39
- "@milaboratories/resolve-helper": "1.1.3",
37
+ "@milaboratories/pl-model-backend": "1.4.8",
38
+ "@milaboratories/pl-model-common": "1.46.2",
39
+ "@milaboratories/ts-helpers": "1.8.3",
40
+ "@milaboratories/pl-model-middle-layer": "1.30.7",
40
41
  "@milaboratories/ts-helpers-oclif": "1.1.42",
41
42
  "@platforma-sdk/blocks-deps-updater": "2.2.0",
42
- "@milaboratories/ts-helpers": "1.8.3",
43
- "@milaboratories/pl-model-backend": "1.4.7"
43
+ "@milaboratories/resolve-helper": "1.1.3"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@rollup/plugin-node-resolve": "^16.0.1",
@@ -15,12 +15,14 @@ To change what "canonical" means, edit `rules/`; the engine is block-agnostic.
15
15
 
16
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
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
+ - Two layers. Calls inside `defineStructure` (`scope`/`fixed`/`managed`/`seed`/`when`-framing) run once at tree-build time to DECLARE the tree — there is no block and no `ctx` yet. The lambdas the engine runs later, per block — trigger predicates, `generate`/`tpl` producers, and `managed` bodies — are the EXECUTION layer, where per-block values are computed.
18
19
 
19
20
  ## Invariants
20
21
 
21
22
  - Fixpoint: `refresh` on an already-canonical block makes zero changes. Every rule must converge.
22
23
  - Generators emit oxfmt-clean output (the `enforce*` calls mirror oxfmt's order), so build→check passes with no prior `fmt`.
23
24
  - 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.
25
+ - Rule code never reaches for `ctx` ambiently — `getActiveRunContext()`/`blockVars()` are engine-internal plumbing. A lambda that needs block identity takes `ctx` by argument (triggers get the richer `TriggerContext`, the rest `RunContext`); a `managed` body that doesn't take it relies on the builders it calls, which resolve `ctx` themselves.
24
26
 
25
27
  ## Gotchas
26
28
 
@@ -0,0 +1,50 @@
1
+ // DISCOVERY derives BlockVars from the block-scope module's package name.
2
+ // This structurer names that package `<facade>.block`, while the root
3
+ // package carries no `name` — so on refresh/check the `.block` suffix would
4
+ // leak into `shortName` unless discovery strips it (see discovery-fs.ts).
5
+ // These tests pin both shapes: the structurer-shaped block (suffix stripped)
6
+ // and a legacy block whose block package IS the bare facade (nothing to
7
+ // strip).
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import type { BlockVars } from "../engine/api";
11
+ import { simulateInit } from "../engine/testing";
12
+ import { discoverRunContext } from "../engine/discovery-fs";
13
+ import { MemoryFileSystem } from "../engine/fs/memory";
14
+
15
+ describe("DISCOVERY: shortName from the block package name", () => {
16
+ test("strips the trailing .block for a <facade>.block-shaped block", () => {
17
+ const vars: BlockVars = {
18
+ facadeName: "@platforma-open/test-org.demo",
19
+ baseName: "test-org.demo",
20
+ npmOrg: "@platforma-open",
21
+ orgScope: "test-org",
22
+ shortName: "demo",
23
+ };
24
+ // simulateInit runs the real init then RE-DISCOVERS from the written FS,
25
+ // so this is the genuine refresh/check derivation path.
26
+ const { ctx } = simulateInit({ vars });
27
+ // Sanity: the block package really is named `<facade>.block` on disk.
28
+ const blockMod = ctx.modules.find((m) => m.scope === "block");
29
+ expect(blockMod?.name).toBe("@platforma-open/test-org.demo.block");
30
+ expect(ctx.blockVars.shortName).toBe("demo");
31
+ });
32
+
33
+ test("keeps the real shortName for a legacy bare-facade block package", () => {
34
+ const fs = new MemoryFileSystem({
35
+ "pnpm-workspace.yaml": "packages:\n - block\n",
36
+ // Nameless root — the standalone shape; classified `root` by path.
37
+ "package.json": "{}",
38
+ // Legacy block package: name IS the bare facade, no `.block` suffix.
39
+ "block/package.json": JSON.stringify({
40
+ name: "@platforma-open/test-org.legacy",
41
+ files: ["index.d.ts", "index.js"],
42
+ dependencies: { "@platforma-sdk/block-tools": "*" },
43
+ }),
44
+ });
45
+ const ctx = discoverRunContext({ fs, isSdkInternal: false });
46
+ const blockMod = ctx.modules.find((m) => m.scope === "block");
47
+ expect(blockMod?.name).toBe("@platforma-open/test-org.legacy");
48
+ expect(ctx.blockVars.shortName).toBe("legacy");
49
+ });
50
+ });
@@ -0,0 +1,68 @@
1
+ // Root CI workflow files (`rules/root-ci.ts`) — `fixed`, generated from the
2
+ // block's short name. These rules are `!isSdkInternal`, so the `etc/blocks`
3
+ // check-blocks run never exercises them; this is their only coverage.
4
+ //
5
+ // Asserts: init writes both workflows with derived identity (app-name /
6
+ // app-name-slug) + shared constants (team-id, the `@v4` pins, test: true);
7
+ // the files are idempotent (init → check zero diff); and `--sdk-internal`
8
+ // blocks get neither file.
9
+
10
+ import { describe, test, expect } from "vitest";
11
+ import type { BlockVars, RunContext } from "../engine/api";
12
+ import { STRUCTURE } from "../structure-definition";
13
+ import { run as engineRun } from "../engine/runner";
14
+ import { defaultTemplateProvider, simulateInit } from "../engine/testing";
15
+
16
+ const TEMPLATES = defaultTemplateProvider();
17
+
18
+ const VARS: BlockVars = {
19
+ facadeName: "@platforma-open/milaboratories.mixcr-clonotyping-2",
20
+ baseName: "milaboratories.mixcr-clonotyping-2",
21
+ npmOrg: "@platforma-open",
22
+ orgScope: "milaboratories",
23
+ shortName: "mixcr-clonotyping-2",
24
+ };
25
+
26
+ const BUILD = ".github/workflows/build.yaml";
27
+ const MARK_STABLE = ".github/workflows/mark-stable.yaml";
28
+
29
+ describe("root CI workflows (fixed, generated)", () => {
30
+ test("init writes both workflows with derived identity + shared constants", () => {
31
+ const { fs } = simulateInit({ vars: VARS });
32
+
33
+ const build = fs.read(BUILD);
34
+ // Derived identity from the short name.
35
+ expect(build).toContain("app-name: 'Block: Mixcr Clonotyping 2'");
36
+ expect(build).toContain("app-name-slug: 'block-mixcr-clonotyping-2'");
37
+ // Shared constants.
38
+ expect(build).toContain("team-id: 'ciplopen'");
39
+ expect(build).toContain("test: true");
40
+ // The reusable-workflow pins — the whole point of engine-owning the file.
41
+ expect(build).toContain(
42
+ "uses: milaboratory/github-ci/.github/workflows/node-simple-pnpm.yaml@v4",
43
+ );
44
+ expect(build).toContain("uses: milaboratory/github-ci/actions/context/init@v4");
45
+ // `${{ ... }}` GitHub expressions survive `tpl` substitution untouched.
46
+ expect(build).toContain("${{ toJSON(secrets.NPMJS_TOKEN) }}");
47
+
48
+ const markStable = fs.read(MARK_STABLE);
49
+ expect(markStable).toContain("app-name: 'Block: Mixcr Clonotyping 2 - Mark Stable'");
50
+ expect(markStable).toContain(
51
+ "uses: milaboratory/github-ci/.github/workflows/block-mark-stable.yaml@v4",
52
+ );
53
+ });
54
+
55
+ test("init → check is zero-diff for the workflow files (fixed idempotency)", () => {
56
+ const { fs, ctx } = simulateInit({ vars: VARS });
57
+ const checkCtx: RunContext = { ...ctx, dryRun: true };
58
+ const result = engineRun(STRUCTURE, fs, checkCtx, { templates: TEMPLATES });
59
+ const ciChanges = result.changes.filter((c) => c.path === BUILD || c.path === MARK_STABLE);
60
+ expect(ciChanges).toEqual([]);
61
+ });
62
+
63
+ test("sdk-internal blocks get neither workflow file", () => {
64
+ const { fs } = simulateInit({ vars: VARS, isSdkInternal: true });
65
+ expect(fs.exists(BUILD)).toBe(false);
66
+ expect(fs.exists(MARK_STABLE)).toBe(false);
67
+ });
68
+ });
@@ -327,18 +327,19 @@ export function text(value: string): ContentForm {
327
327
  }
328
328
 
329
329
  /** Content form: a text template (the structurer's `text/` templates) with
330
- * `${var}` placeholders substituted from `vars`. */
330
+ * `${var}` placeholders substituted from `vars`. When `vars` is a function it
331
+ * receives the active {@link RunContext} at resolve time. */
331
332
  export function tpl(
332
333
  path: string,
333
- vars: Record<string, string> | (() => Record<string, string>),
334
+ vars: Record<string, string> | ((ctx: RunContext) => Record<string, string>),
334
335
  ): ContentForm {
335
336
  return { kind: "tpl", path, vars };
336
337
  }
337
338
 
338
- /** Content form: content computed at run time. The return value is serialised
339
- * as JSON / YAML by the target file's extension, or used verbatim if it is
340
- * already a string. */
341
- export function generate(fn: () => unknown): ContentForm {
339
+ /** Content form: content computed at run time from the active
340
+ * {@link RunContext}. The return value is serialised as JSON / YAML by the
341
+ * target file's extension, or used verbatim if it is already a string. */
342
+ export function generate(fn: (ctx: RunContext) => unknown): ContentForm {
342
343
  return { kind: "generate", fn };
343
344
  }
344
345
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { registerInnerWhenHandler, tryGetActiveRunContext } from "./builders";
15
15
  import type { RunContext, Scope } from "./api";
16
- import type { TriggerContext, TriggerFn } from "./ir";
16
+ import type { ManagedBody, TriggerContext, TriggerFn } from "./ir";
17
17
  import {
18
18
  deleteAtPath,
19
19
  getAtPath,
@@ -163,13 +163,13 @@ export type WithManagedOpts = {
163
163
  * the runner calls this for managed `.json` files. */
164
164
  export function withManagedBody(
165
165
  obj: JsonObject,
166
- body: () => void,
166
+ body: ManagedBody,
167
167
  opts: WithManagedOpts = {},
168
168
  ): JsonObject {
169
169
  if (active) throw new Error("Nested managed(...) body — engine bug.");
170
170
  active = { kind: "json", obj, ctx: opts.ctx, triggerContext: opts.triggerContext };
171
171
  try {
172
- body();
172
+ body((opts.ctx ?? opts.triggerContext?.ctx) as RunContext);
173
173
  return obj;
174
174
  } finally {
175
175
  active = undefined;
@@ -189,7 +189,7 @@ export type WithManagedYamlOpts = WithManagedOpts & {
189
189
  * `pnpm-workspace.yaml`); returns the mutated document. Exported for tests. */
190
190
  export function withManagedYaml(
191
191
  doc: YamlDocument,
192
- body: () => void,
192
+ body: ManagedBody,
193
193
  opts: WithManagedYamlOpts = {},
194
194
  ): YamlDocument {
195
195
  if (active) throw new Error("Nested managed(...) body — engine bug.");
@@ -202,7 +202,7 @@ export function withManagedYaml(
202
202
  getDerivedDependencyPin: opts.getDerivedDependencyPin,
203
203
  };
204
204
  try {
205
- body();
205
+ body((opts.ctx ?? opts.triggerContext?.ctx) as RunContext);
206
206
  return doc;
207
207
  } finally {
208
208
  active = undefined;
@@ -213,7 +213,7 @@ export function withManagedYaml(
213
213
  * (for `.gitignore`); returns the mutated lines. Exported for tests. */
214
214
  export function withManagedLines(
215
215
  lines: string[],
216
- body: () => void,
216
+ body: ManagedBody,
217
217
  opts: WithManagedOpts = {},
218
218
  ): string[] {
219
219
  if (active) throw new Error("Nested managed(...) body — engine bug.");
@@ -224,7 +224,7 @@ export function withManagedLines(
224
224
  triggerContext: opts.triggerContext,
225
225
  };
226
226
  try {
227
- body();
227
+ body((opts.ctx ?? opts.triggerContext?.ctx) as RunContext);
228
228
  return lines;
229
229
  } finally {
230
230
  active = undefined;
@@ -196,7 +196,15 @@ export function discoverRunContext(input: DiscoverInput): RunContext {
196
196
  `Could not find a 'block' (or root-as-block) package to derive BlockVars from.`,
197
197
  );
198
198
  }
199
- const blockVars = parseBlockVars(blockMod.name);
199
+ // This structurer names the block package `<facade>.block`
200
+ // (block-package-json.ts). The root package carries no `name`, so DISCOVERY
201
+ // derives BlockVars from the block module — whose `.block` suffix would
202
+ // otherwise leak into `shortName` on refresh/check (but not init, which is
203
+ // fed the facade directly). Strip it so `shortName` is the real short name
204
+ // everywhere. No-op for legacy blocks whose block package IS the bare facade
205
+ // and for the root-as-block fallback.
206
+ const facadeName = blockMod.name.replace(/\.block$/, "");
207
+ const blockVars = parseBlockVars(facadeName);
200
208
 
201
209
  return createRunContext({
202
210
  isSdkInternal,
@@ -37,21 +37,26 @@ export type TriggerContext = {
37
37
  export type TriggerFn = (tctx: TriggerContext) => boolean;
38
38
 
39
39
  /** Content forms — what to write into a file when creating it.
40
- * `tpl.vars` may be a record or a thunk returning a record; the latter
41
- * lets rule authors call `blockVars()` lazily at resolve time. */
42
- export type TplVarsLike = Record<string, string> | (() => Record<string, string>);
40
+ * `tpl.vars` and `generate.fn` may be functions; when they are, the
41
+ * resolver calls them with the active `RunContext` (the same object
42
+ * triggers see as `tctx.ctx`), so lambdas read block identity via their
43
+ * argument rather than the module-global `getActiveRunContext()`. */
44
+ export type TplVarsLike = Record<string, string> | ((ctx: RunContext) => Record<string, string>);
43
45
  export type ContentForm =
44
46
  | { kind: "file"; path: string }
45
47
  | { kind: "text"; value: string }
46
48
  | { kind: "tpl"; path: string; vars: TplVarsLike }
47
- | { kind: "generate"; fn: () => unknown }
49
+ | { kind: "generate"; fn: (ctx: RunContext) => unknown }
48
50
  // A binary static asset (e.g. a logo PNG) copied verbatim from the
49
51
  // template tree. Carried as raw bytes, never decoded to a string —
50
52
  // only `seed` / `scaffold` consume it (via fs.writeBinary).
51
53
  | { kind: "binary"; path: string };
52
54
 
53
- /** Builder-body for a `managed` file (the content-rules lambda). */
54
- export type ManagedBody = () => void;
55
+ /** Builder-body for a `managed` file (the content-rules lambda). Receives the
56
+ * active `RunContext` — the same object triggers see as `tctx.ctx` and that
57
+ * `generate`/`tpl` lambdas receive — so the body reads block identity via its
58
+ * argument rather than the module-global `getActiveRunContext()`. */
59
+ export type ManagedBody = (ctx: RunContext) => void;
55
60
 
56
61
  // --- Leaf primitives ---
57
62
 
@@ -110,7 +110,7 @@ function resolveContent(
110
110
  form: ContentForm,
111
111
  filePath: string,
112
112
  templates: TemplateProvider | undefined,
113
- isSdkInternal: boolean,
113
+ ctx: RunContext,
114
114
  ): string {
115
115
  switch (form.kind) {
116
116
  case "text":
@@ -132,15 +132,15 @@ function resolveContent(
132
132
  );
133
133
  }
134
134
  const raw = templates.textRead(form.path);
135
- const vars = typeof form.vars === "function" ? form.vars() : form.vars;
135
+ const vars = typeof form.vars === "function" ? form.vars(ctx) : form.vars;
136
136
  return substituteVars(raw, vars);
137
137
  }
138
138
  case "generate": {
139
- const value = form.fn();
139
+ const value = form.fn(ctx);
140
140
  if (filePath.endsWith(".json")) {
141
141
  // Resolve `sdk:` sentinels in the generated dep sections so the
142
142
  // initial content matches what the body rules re-assert.
143
- resolveSdkSentinelsInJson(value, isSdkInternal);
143
+ resolveSdkSentinelsInJson(value, ctx.isSdkInternal);
144
144
  // package.json stays fully expanded (oxfmt's package.json shape);
145
145
  // every other .json collapses to oxfmt's generic-file shape.
146
146
  return stringifyJson(value, { collapse: !filePath.endsWith("package.json") });
@@ -170,7 +170,7 @@ function writeLeafContent(
170
170
  resolvedPath: string,
171
171
  form: ContentForm,
172
172
  templates: TemplateProvider | undefined,
173
- isSdkInternal: boolean,
173
+ ctx: RunContext,
174
174
  ): void {
175
175
  if (form.kind === "binary") {
176
176
  if (!templates) {
@@ -182,7 +182,7 @@ function writeLeafContent(
182
182
  fs.writeBinary(resolvedPath, templates.staticReadBinary(form.path));
183
183
  return;
184
184
  }
185
- fs.write(resolvedPath, resolveContent(form, resolvedPath, templates, isSdkInternal));
185
+ fs.write(resolvedPath, resolveContent(form, resolvedPath, templates, ctx));
186
186
  }
187
187
 
188
188
  /** Dispatch managed-body parse → mutate → serialize by file extension /
@@ -292,11 +292,11 @@ function applyStructural(
292
292
  dryRun: boolean,
293
293
  templates: TemplateProvider | undefined,
294
294
  initMode: boolean,
295
- isSdkInternal: boolean,
295
+ ctx: RunContext,
296
296
  ): Change | undefined {
297
297
  const leaf = item.leaf;
298
298
  if (leaf.kind === "fixed") {
299
- const desired = resolveContent(leaf.content, item.resolvedPath, templates, isSdkInternal);
299
+ const desired = resolveContent(leaf.content, item.resolvedPath, templates, ctx);
300
300
  const exists = fs.exists(item.resolvedPath);
301
301
  if (!exists) {
302
302
  if (!dryRun) fs.write(item.resolvedPath, desired);
@@ -320,7 +320,7 @@ function applyStructural(
320
320
  if (leaf.kind === "scaffold") {
321
321
  if (fs.exists(item.resolvedPath)) return undefined;
322
322
  if (!dryRun) {
323
- writeLeafContent(fs, item.resolvedPath, leaf.initial, templates, isSdkInternal);
323
+ writeLeafContent(fs, item.resolvedPath, leaf.initial, templates, ctx);
324
324
  }
325
325
  return {
326
326
  scope: item.scope,
@@ -338,7 +338,7 @@ function applyStructural(
338
338
  if (!initMode) return undefined;
339
339
  if (fs.exists(item.resolvedPath)) return undefined;
340
340
  if (!dryRun) {
341
- writeLeafContent(fs, item.resolvedPath, leaf.initial, templates, isSdkInternal);
341
+ writeLeafContent(fs, item.resolvedPath, leaf.initial, templates, ctx);
342
342
  }
343
343
  return {
344
344
  scope: item.scope,
@@ -383,7 +383,7 @@ function applyManaged(
383
383
  templates: TemplateProvider | undefined,
384
384
  registryLookup: ((packageName: string) => string | undefined) | undefined,
385
385
  derivedPinLookup: ((catalogEntry: string) => string | undefined) | undefined,
386
- isSdkInternal: boolean,
386
+ ctx: RunContext,
387
387
  ): Change | undefined {
388
388
  if (item.leaf.kind !== "managed") return undefined;
389
389
  const leaf = item.leaf;
@@ -394,7 +394,7 @@ function applyManaged(
394
394
  let created = false;
395
395
 
396
396
  if (!exists) {
397
- beforeRaw = resolveContent(leaf.initial, path, templates, isSdkInternal);
397
+ beforeRaw = resolveContent(leaf.initial, path, templates, ctx);
398
398
  created = true;
399
399
  } else {
400
400
  beforeRaw = fs.read(path);
@@ -443,7 +443,7 @@ function executePass(
443
443
  if (item.leaf.kind === "managed") continue;
444
444
  const tctx1 = buildTriggerCtx(snap1, ctx, item.modulePath);
445
445
  if (!passesTriggers(item, tctx1)) continue;
446
- const change = applyStructural(item, fs, dryRun, templates, initMode, ctx.isSdkInternal);
446
+ const change = applyStructural(item, fs, dryRun, templates, initMode, ctx);
447
447
  if (change) changes.push(change);
448
448
  }
449
449
 
@@ -462,7 +462,7 @@ function executePass(
462
462
  templates,
463
463
  registryLookup,
464
464
  derivedPinLookup,
465
- ctx.isSdkInternal,
465
+ ctx,
466
466
  );
467
467
  if (change) changes.push(change);
468
468
  }
@@ -12,7 +12,6 @@ import {
12
12
  enforceFieldOrder,
13
13
  type RunContext,
14
14
  } from "../engine/api";
15
- import { getActiveRunContext } from "../engine/builders";
16
15
  import { findModules, scopeDepMap } from "../engine/ctx";
17
16
  import { canonicalPackageJsonOrder } from "./shared/key-order";
18
17
 
@@ -111,7 +110,7 @@ export function blockPackageJsonInitial(ctx: RunContext): Record<string, unknown
111
110
  };
112
111
  }
113
112
 
114
- export function blockPackageJsonRules(): void {
113
+ export function blockPackageJsonRules(ctx: RunContext): void {
115
114
  // type:"module" intentionally omitted: the facade's index.js is the
116
115
  // CommonJS dev-block descriptor (module.exports / __dirname); ESM would
117
116
  // break it. The facade has no TS sources to compile.
@@ -146,7 +145,7 @@ export function blockPackageJsonRules(): void {
146
145
  // it in sync on refresh. `block.meta` is deliberately NOT touched here: it
147
146
  // is an author-owned seed (set once in the init package.json), so refresh
148
147
  // must never overwrite the author's title / description / logo.
149
- ensureField("block.components", blockComponents(getActiveRunContext()));
148
+ ensureField("block.components", blockComponents(ctx));
150
149
 
151
150
  enforceAlphabeticalOrder("dependencies");
152
151
  enforceAlphabeticalOrder("devDependencies");
@@ -2,7 +2,6 @@
2
2
  // Workspace-scope deps in the body resolve from ctx.modules.
3
3
 
4
4
  import { scope, fixed, managed, seed, file, binaryFile, generate } from "../engine/api";
5
- import { getActiveRunContext } from "../engine/builders";
6
5
  import { blockPackageJsonInitial, blockPackageJsonRules } from "./block-package-json";
7
6
 
8
7
  export function blockRules(): void {
@@ -20,9 +19,9 @@ export function blockRules(): void {
20
19
 
21
20
  managed(
22
21
  "package.json",
23
- generate(() => blockPackageJsonInitial(getActiveRunContext())),
24
- () => {
25
- blockPackageJsonRules();
22
+ generate((ctx) => blockPackageJsonInitial(ctx)),
23
+ (ctx) => {
24
+ blockPackageJsonRules(ctx);
26
25
  },
27
26
  );
28
27
  });
@@ -12,7 +12,6 @@
12
12
  // keys/paths). Each line is a one-way decision: every block converges.
13
13
 
14
14
  import { scope, remove, managed, generate, removeScript, removeDep } from "../engine/api";
15
- import { getActiveRunContext } from "../engine/builders";
16
15
  import { rootPackageJsonInitial } from "./root-package-json";
17
16
 
18
17
  export function testFrameworkMigration(): void {
@@ -50,7 +49,7 @@ export function legacyCleanup(): void {
50
49
  remove(".prettierrc");
51
50
  managed(
52
51
  "package.json",
53
- generate(() => rootPackageJsonInitial(getActiveRunContext())),
52
+ generate((ctx) => rootPackageJsonInitial(ctx)),
54
53
  () => {
55
54
  // `pretty` (prettier) is superseded by `fmt` (oxfmt); the standalone
56
55
  // deps-updater is now part of block-tools.
@@ -3,7 +3,6 @@
3
3
  // (block author owns it).
4
4
 
5
5
  import { scope, fixed, managed, seed, file, generate, when, whenFilesExist } from "../engine/api";
6
- import { getActiveRunContext } from "../engine/builders";
7
6
  import { modelPackageJsonInitial, modelPackageJsonRules } from "./model-package-json";
8
7
  import { COLOCATED_TEST_GLOB } from "./shared/colocated-tests";
9
8
 
@@ -32,7 +31,7 @@ export function modelRules(): void {
32
31
 
33
32
  managed(
34
33
  "package.json",
35
- generate(() => modelPackageJsonInitial(getActiveRunContext())),
34
+ generate((ctx) => modelPackageJsonInitial(ctx)),
36
35
  () => {
37
36
  modelPackageJsonRules();
38
37
  },
@@ -28,7 +28,6 @@ import {
28
28
  ensureCatalogVersion,
29
29
  pinCatalogToDependencyOf,
30
30
  } from "../engine/api";
31
- import { getActiveRunContext } from "../engine/builders";
32
31
  import {
33
32
  rootPnpmWorkspaceInitial,
34
33
  SDK_CATALOG_PACKAGES,
@@ -53,8 +52,8 @@ export function rootCatalogBumpRules(): void {
53
52
  // generator.
54
53
  managed(
55
54
  "pnpm-workspace.yaml",
56
- generate(() => rootPnpmWorkspaceInitial(getActiveRunContext())),
57
- () => {
55
+ generate((ctx) => rootPnpmWorkspaceInitial(ctx)),
56
+ (ctx) => {
58
57
  // SDK families → npm latest, ADD-IF-ABSENT (seeds a migrated
59
58
  // block's missing standard keys + overwrites the init
60
59
  // placeholder).
@@ -76,7 +75,7 @@ export function rootCatalogBumpRules(): void {
76
75
  }
77
76
  // The python runenv pin is relevant only to software-bearing
78
77
  // blocks; seed it add-if-absent when one is present.
79
- if (getActiveRunContext().modules.some((m) => m.scope === "software")) {
78
+ if (ctx.modules.some((m) => m.scope === "software")) {
80
79
  ensureCatalogVersion(RUNENV_PYTHON, RUNENV_PYTHON_VERSION);
81
80
  }
82
81
  },
@@ -0,0 +1,55 @@
1
+ // Root-scope CI workflows — the block's GitHub Actions `build.yaml` and
2
+ // `mark-stable.yaml`. Engine-OWNED (`fixed`): rewritten verbatim on every
3
+ // refresh, generated from the block's identity (short name); everything else
4
+ // is a constant baked into the templates.
5
+ //
6
+ // Why fixed, not managed: previously each block carried a hand-copied
7
+ // workflow pinning the shared reusable workflow at `@v4` (a moving branch),
8
+ // so a breaking shared-CI change was absorbed silently and per-block edits
9
+ // drifted. Engine-owning the file makes the pin + job wiring centrally
10
+ // refreshable; the only per-block bits are derived (app-name, slug), so
11
+ // nothing author-tunable is clobbered.
12
+ //
13
+ // Standalone blocks only — `--sdk-internal` blocks (`etc/blocks/*`) carry no
14
+ // per-block workflow (the monorepo owns their CI), hence the
15
+ // `when(!isSdkInternal)` guard, matching the rest of the root scope.
16
+
17
+ import { scope, when, fixed, tpl } from "../engine/api";
18
+
19
+ /** Readable default CI label from a short name (`mixcr-clonotyping-2` →
20
+ * `Mixcr Clonotyping 2`). Dash/underscore words, title-cased. Human-curated
21
+ * casing (e.g. `MiXCR`) is not recoverable from the slug — this is a
22
+ * generated label, not the marketing name. */
23
+ function humanizeShortName(shortName: string): string {
24
+ return shortName
25
+ .split(/[-_]+/)
26
+ .filter((w) => w.length > 0)
27
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
28
+ .join(" ");
29
+ }
30
+
31
+ export function rootCiRules(): void {
32
+ scope("root", () => {
33
+ when(
34
+ ({ ctx }) => !ctx.isSdkInternal,
35
+ () => {
36
+ fixed(
37
+ ".github/workflows/build.yaml",
38
+ tpl("workflows/build.tpl.yaml", (ctx) => {
39
+ const shortName = ctx.blockVars.shortName;
40
+ return {
41
+ appName: `Block: ${humanizeShortName(shortName)}`,
42
+ appNameSlug: `block-${shortName}`,
43
+ };
44
+ }),
45
+ );
46
+ fixed(
47
+ ".github/workflows/mark-stable.yaml",
48
+ tpl("workflows/mark-stable.tpl.yaml", (ctx) => ({
49
+ appName: `Block: ${humanizeShortName(ctx.blockVars.shortName)} - Mark Stable`,
50
+ })),
51
+ );
52
+ },
53
+ );
54
+ });
55
+ }
@@ -4,7 +4,6 @@
4
4
  // `when(({ ctx }) => !ctx.isSdkInternal, ...)` wrapper.
5
5
 
6
6
  import { scope, when, fixed, managed, file, generate } from "../engine/api";
7
- import { getActiveRunContext } from "../engine/builders";
8
7
  import { rootPackageJsonInitial, rootPackageJsonRules } from "./root-package-json";
9
8
  import { rootPnpmWorkspaceInitial, rootPnpmWorkspaceRules } from "./root-pnpm-workspace";
10
9
  import { rootGitignoreRules } from "./root-gitignore";
@@ -19,7 +18,7 @@ export function rootRules(): void {
19
18
 
20
19
  managed(
21
20
  "package.json",
22
- generate(() => rootPackageJsonInitial(getActiveRunContext())),
21
+ generate((ctx) => rootPackageJsonInitial(ctx)),
23
22
  () => {
24
23
  rootPackageJsonRules();
25
24
  },
@@ -27,7 +26,7 @@ export function rootRules(): void {
27
26
 
28
27
  managed(
29
28
  "pnpm-workspace.yaml",
30
- generate(() => rootPnpmWorkspaceInitial(getActiveRunContext())),
29
+ generate((ctx) => rootPnpmWorkspaceInitial(ctx)),
31
30
  () => {
32
31
  rootPnpmWorkspaceRules();
33
32
  },
@@ -6,7 +6,6 @@
6
6
  // `init` and never touched again — the block author owns them.
7
7
 
8
8
  import { scope, managed, seed, generate, text } from "../engine/api";
9
- import { getActiveRunContext } from "../engine/builders";
10
9
  import { softwarePackageJsonInitial, softwarePackageJsonRules } from "./software-package-json";
11
10
 
12
11
  const MAIN_PY_SEED = `import sys
@@ -24,7 +23,7 @@ export function softwareRules(): void {
24
23
 
25
24
  managed(
26
25
  "package.json",
27
- generate(() => softwarePackageJsonInitial(getActiveRunContext())),
26
+ generate((ctx) => softwarePackageJsonInitial(ctx)),
28
27
  () => {
29
28
  softwarePackageJsonRules();
30
29
  },
@@ -1,7 +1,6 @@
1
1
  // Test-scope rules.
2
2
 
3
3
  import { scope, fixed, managed, scaffold, seed, file, text, generate } from "../engine/api";
4
- import { getActiveRunContext } from "../engine/builders";
5
4
  import { testPackageJsonInitial, testPackageJsonRules } from "./test-package-json";
6
5
 
7
6
  export function testRules(): void {
@@ -35,7 +34,7 @@ export function testRules(): void {
35
34
 
36
35
  managed(
37
36
  "package.json",
38
- generate(() => testPackageJsonInitial(getActiveRunContext())),
37
+ generate((ctx) => testPackageJsonInitial(ctx)),
39
38
  () => {
40
39
  testPackageJsonRules();
41
40
  },