@platforma-sdk/block-tools 2.10.18 → 2.11.0

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 (42) hide show
  1. package/dist/cli.js +12 -12
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +391 -355
  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/structure/rules/shared/retired-deps.d.ts +7 -0
  11. package/package.json +7 -7
  12. package/src/structure/CLAUDE.md +30 -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 +16 -18
  23. package/src/structure/rules/model-package-json.ts +4 -5
  24. package/src/structure/rules/model.ts +1 -2
  25. package/src/structure/rules/root-catalog-bump.ts +3 -4
  26. package/src/structure/rules/root-ci.ts +55 -0
  27. package/src/structure/rules/root-package-json.ts +8 -6
  28. package/src/structure/rules/root-pnpm-workspace.ts +4 -7
  29. package/src/structure/rules/root.ts +2 -4
  30. package/src/structure/rules/shared/retired-deps.ts +42 -0
  31. package/src/structure/rules/software.ts +1 -2
  32. package/src/structure/rules/test-package-json.ts +12 -9
  33. package/src/structure/rules/test.ts +5 -2
  34. package/src/structure/rules/ui-package-json.ts +15 -21
  35. package/src/structure/rules/ui.ts +3 -5
  36. package/src/structure/rules/workflow-package-json.ts +7 -3
  37. package/src/structure/rules/workflow.ts +20 -17
  38. package/src/structure/structure-definition.ts +2 -0
  39. package/src/structure/templates/static/test/.oxfmtrc.json +4 -0
  40. package/src/structure/templates/static/test/.oxlintrc.json +3 -0
  41. package/src/structure/templates/text/workflows/build.tpl.yaml +67 -0
  42. package/src/structure/templates/text/workflows/mark-stable.tpl.yaml +34 -0
@@ -82,12 +82,13 @@ export declare function binaryFile(path: string): ContentForm;
82
82
  /** Content form: an inline literal string. */
83
83
  export declare function text(value: string): ContentForm;
84
84
  /** Content form: a text template (the structurer's `text/` templates) with
85
- * `${var}` placeholders substituted from `vars`. */
86
- export declare function tpl(path: string, vars: Record<string, string> | (() => Record<string, string>)): ContentForm;
87
- /** Content form: content computed at run time. The return value is serialised
88
- * as JSON / YAML by the target file's extension, or used verbatim if it is
89
- * already a string. */
90
- export declare function generate(fn: () => unknown): ContentForm;
85
+ * `${var}` placeholders substituted from `vars`. When `vars` is a function it
86
+ * receives the active {@link RunContext} at resolve time. */
87
+ export declare function tpl(path: string, vars: Record<string, string> | ((ctx: RunContext) => Record<string, string>)): ContentForm;
88
+ /** Content form: content computed at run time from the active
89
+ * {@link RunContext}. The return value is serialised as JSON / YAML by the
90
+ * target file's extension, or used verbatim if it is already a string. */
91
+ export declare function generate(fn: (ctx: RunContext) => unknown): ContentForm;
91
92
  /** The active run's {@link BlockVars} (block name parts + software platform).
92
93
  * Valid only during a run (inside a generator or managed body); throws
93
94
  * otherwise. */
@@ -1,5 +1,5 @@
1
1
  import { RunContext, Scope } from './api';
2
- import { TriggerContext } from './ir';
2
+ import { ManagedBody, TriggerContext } from './ir';
3
3
  import { JsonObject } from './parsers/json';
4
4
  import { YamlDocument } from './parsers/yaml';
5
5
  export type { JsonObject };
@@ -30,7 +30,7 @@ export type WithManagedOpts = {
30
30
  /** Engine-internal: run `body` with `obj` as the active JSON state so the
31
31
  * JSON builders mutate it; returns the mutated object. Exported for tests —
32
32
  * the runner calls this for managed `.json` files. */
33
- export declare function withManagedBody(obj: JsonObject, body: () => void, opts?: WithManagedOpts): JsonObject;
33
+ export declare function withManagedBody(obj: JsonObject, body: ManagedBody, opts?: WithManagedOpts): JsonObject;
34
34
  export type WithManagedYamlOpts = WithManagedOpts & {
35
35
  /** Sync accessor for prefetched latest versions. Required for
36
36
  * `ensureCatalogLatest` to take effect. */
@@ -41,10 +41,10 @@ export type WithManagedYamlOpts = WithManagedOpts & {
41
41
  };
42
42
  /** Engine-internal: run `body` with `doc` as the active YAML state (for
43
43
  * `pnpm-workspace.yaml`); returns the mutated document. Exported for tests. */
44
- export declare function withManagedYaml(doc: YamlDocument, body: () => void, opts?: WithManagedYamlOpts): YamlDocument;
44
+ export declare function withManagedYaml(doc: YamlDocument, body: ManagedBody, opts?: WithManagedYamlOpts): YamlDocument;
45
45
  /** Engine-internal: run `body` with `lines` as the active line-based state
46
46
  * (for `.gitignore`); returns the mutated lines. Exported for tests. */
47
- export declare function withManagedLines(lines: string[], body: () => void, opts?: WithManagedOpts): string[];
47
+ export declare function withManagedLines(lines: string[], body: ManagedBody, opts?: WithManagedOpts): string[];
48
48
  /** Set the value at `jsonPath` in the active managed JSON object. */
49
49
  export declare function ensureField(jsonPath: string, value: unknown): void;
50
50
  /** Remove the field at `jsonPath`. With `predicate`, only removes when
@@ -24,9 +24,11 @@ export type TriggerContext = {
24
24
  };
25
25
  export type TriggerFn = (tctx: TriggerContext) => boolean;
26
26
  /** Content forms — what to write into a file when creating it.
27
- * `tpl.vars` may be a record or a thunk returning a record; the latter
28
- * lets rule authors call `blockVars()` lazily at resolve time. */
29
- export type TplVarsLike = Record<string, string> | (() => Record<string, string>);
27
+ * `tpl.vars` and `generate.fn` may be functions; when they are, the
28
+ * resolver calls them with the active `RunContext` (the same object
29
+ * triggers see as `tctx.ctx`), so lambdas read block identity via their
30
+ * argument rather than the module-global `getActiveRunContext()`. */
31
+ export type TplVarsLike = Record<string, string> | ((ctx: RunContext) => Record<string, string>);
30
32
  export type ContentForm = {
31
33
  kind: "file";
32
34
  path: string;
@@ -39,13 +41,16 @@ export type ContentForm = {
39
41
  vars: TplVarsLike;
40
42
  } | {
41
43
  kind: "generate";
42
- fn: () => unknown;
44
+ fn: (ctx: RunContext) => unknown;
43
45
  } | {
44
46
  kind: "binary";
45
47
  path: string;
46
48
  };
47
- /** Builder-body for a `managed` file (the content-rules lambda). */
48
- export type ManagedBody = () => void;
49
+ /** Builder-body for a `managed` file (the content-rules lambda). Receives the
50
+ * active `RunContext` — the same object triggers see as `tctx.ctx` and that
51
+ * `generate`/`tpl` lambdas receive — so the body reads block identity via its
52
+ * argument rather than the module-global `getActiveRunContext()`. */
53
+ export type ManagedBody = (ctx: RunContext) => void;
49
54
  export type FixedItem = {
50
55
  kind: "fixed";
51
56
  path: string;
@@ -6,4 +6,4 @@ import { RunContext } from '../engine/api';
6
6
  * workflow → pl-tengo, model → build-model, ui → ts-builder. */
7
7
  export declare function blockComponents(ctx: RunContext): Record<string, string>;
8
8
  export declare function blockPackageJsonInitial(ctx: RunContext): Record<string, unknown>;
9
- export declare function blockPackageJsonRules(): void;
9
+ export declare function blockPackageJsonRules(ctx: RunContext): void;
@@ -0,0 +1 @@
1
+ export declare function rootCiRules(): void;
@@ -0,0 +1,7 @@
1
+ export declare const RETIRED_TOOLCHAIN_DEPS: readonly string[];
2
+ /** Drop every retired toolchain dep from the current package.json scope.
3
+ * Idempotent (a `removeDep` on an absent key is a no-op), so it is safe to
4
+ * call uniformly from every package.json rules body regardless of which deps
5
+ * a given scope ever carried. Pairs with the catalog-side removal in
6
+ * `rootPnpmWorkspaceRules`. */
7
+ export declare function removeRetiredToolchainDeps(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/block-tools",
3
- "version": "2.10.18",
3
+ "version": "2.11.0",
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-middle-layer": "1.30.6",
37
+ "@milaboratories/pl-model-backend": "1.4.7",
38
38
  "@milaboratories/pl-model-common": "1.46.1",
39
+ "@milaboratories/pl-model-middle-layer": "1.30.6",
39
40
  "@milaboratories/resolve-helper": "1.1.3",
40
- "@milaboratories/pl-model-backend": "1.4.7",
41
41
  "@milaboratories/ts-helpers": "1.8.3",
42
- "@milaboratories/ts-helpers-oclif": "1.1.42",
43
- "@platforma-sdk/blocks-deps-updater": "2.2.0"
42
+ "@platforma-sdk/blocks-deps-updater": "2.2.0",
43
+ "@milaboratories/ts-helpers-oclif": "1.1.42"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@rollup/plugin-node-resolve": "^16.0.1",
@@ -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
- "@milaboratories/ts-configs": "1.2.3",
61
61
  "@milaboratories/ts-builder": "1.5.2",
62
- "@milaboratories/build-configs": "2.0.0"
62
+ "@milaboratories/ts-configs": "1.2.3"
63
63
  },
64
64
  "oclif": {
65
65
  "bin": "block-tools",
@@ -0,0 +1,30 @@
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
+ - 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.
19
+
20
+ ## Invariants
21
+
22
+ - Fixpoint: `refresh` on an already-canonical block makes zero changes. Every rule must converge.
23
+ - Generators emit oxfmt-clean output (the `enforce*` calls mirror oxfmt's order), so build→check passes with no prior `fmt`.
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.
26
+
27
+ ## Gotchas
28
+
29
+ - A rule change also changes the canonical for `etc/blocks/*`: refresh them in the SAME PR (`pnpm run check-blocks`) or CI fails.
30
+ - 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.
@@ -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
  });