@platforma-sdk/block-tools 2.11.3 → 2.11.5

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 (51) hide show
  1. package/dist/cli.js +13 -13
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +634 -580
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/config-BmJWc8sR.js +3 -0
  6. package/dist/config-BmJWc8sR.js.map +1 -0
  7. package/dist/{config-DpfTzcgG.mjs → config-CiC347sB.mjs} +453 -398
  8. package/dist/config-CiC347sB.mjs.map +1 -0
  9. package/dist/index.js +1 -1
  10. package/dist/index.mjs +15 -15
  11. package/dist/structure/engine/discovery.d.ts +2 -3
  12. package/dist/structure/rules/block-package-json.d.ts +3 -3
  13. package/dist/structure/rules/shared/pascal-case.d.ts +1 -0
  14. package/dist/v2/index.d.ts +1 -0
  15. package/dist/v2/model/block_components.d.ts +11 -0
  16. package/dist/v2/model/block_meta.d.ts +7 -0
  17. package/dist/v2/registry/schema_public.d.ts +3600 -3600
  18. package/dist/v2/resolve_to_registry.d.ts +37 -0
  19. package/dist/v2/source_package.d.ts +12 -0
  20. package/package.json +8 -8
  21. package/src/cmd/publish.test.ts +35 -0
  22. package/src/cmd/publish.ts +20 -0
  23. package/src/structure/__tests__/discovery-shortname.test.ts +3 -0
  24. package/src/structure/__tests__/facade-end-state.test.ts +195 -0
  25. package/src/structure/__tests__/model-package-json.snapshot.test.ts +1 -1
  26. package/src/structure/engine/__tests__/discovery.test.ts +22 -26
  27. package/src/structure/engine/discovery.ts +12 -30
  28. package/src/structure/rules/block-package-json.ts +146 -64
  29. package/src/structure/rules/block.ts +40 -5
  30. package/src/structure/rules/model-package-json.ts +6 -1
  31. package/src/structure/rules/shared/pascal-case.ts +16 -0
  32. package/src/structure/rules/software-package-json.ts +6 -1
  33. package/src/structure/rules/test-package-json.ts +5 -1
  34. package/src/structure/rules/ui-package-json.ts +5 -1
  35. package/src/structure/rules/workflow-package-json.ts +5 -1
  36. package/src/structure/templates/static/block/src/AGENTS.ts +5 -0
  37. package/src/structure/templates/static/block/src/agents-extra.ts +8 -0
  38. package/src/structure/templates/static/block/src/block-extra.ts +9 -0
  39. package/src/structure/templates/static/block/tsconfig.json +4 -0
  40. package/src/structure/templates/text/block/src/index.tpl.ts +55 -0
  41. package/src/v2/from_pack_v2.test.ts +142 -0
  42. package/src/v2/index.ts +1 -0
  43. package/src/v2/model/block_components.ts +33 -0
  44. package/src/v2/model/block_meta.ts +18 -0
  45. package/src/v2/resolve_to_registry.ts +84 -0
  46. package/src/v2/source_package.ts +34 -1
  47. package/dist/config-DpfTzcgG.mjs.map +0 -1
  48. package/dist/config-xxVy3IQR.js +0 -3
  49. package/dist/config-xxVy3IQR.js.map +0 -1
  50. package/src/structure/templates/static/block/index.d.ts +0 -6
  51. package/src/structure/templates/static/block/index.js +0 -8
@@ -0,0 +1,37 @@
1
+ import { BlockPackDevV2, BlockPackFromPackV2, BlockPackFromRegistryV2, BlockPackId } from '@milaboratories/pl-model-middle-layer';
2
+ /** File written by `block-tools publish` into `block-pack/`, carrying the
3
+ * registry coordinates of the published version. Holds only the immutable
4
+ * coordinates (CDN serve URL + version id). No `channel`: channels are moving
5
+ * pointers in the registry — a version can be promoted/moved later — so the
6
+ * channel at publish time is transient state we can't pin here. Update-checking
7
+ * resolves the channel at read time (`BlockPackFromRegistryV2.channel` is
8
+ * optional; the watcher defaults stable → any). */
9
+ export declare const PublishedCoordsFileName = "published.json";
10
+ /** Registry coordinates persisted in `block-pack/published.json`. */
11
+ export interface PublishedCoords {
12
+ /** CDN serve URL the block is fetched from (not the S3 upload bucket). */
13
+ registryUrl: string;
14
+ id: BlockPackId;
15
+ }
16
+ /**
17
+ * Builds the `published.json` payload. Pure (no filesystem) so it is unit
18
+ * testable.
19
+ */
20
+ export declare function buildPublishedCoords(args: {
21
+ registryUrl: string;
22
+ id: BlockPackId;
23
+ }): PublishedCoords;
24
+ /** Writes `block-pack/published.json` into the given block-pack folder. */
25
+ export declare function writePublishedCoords(blockPackDir: string, coords: PublishedCoords): Promise<void>;
26
+ /**
27
+ * Resolves a local block locator (`dev-v2` or `from-pack-v2`) to its registry
28
+ * coordinates by reading `published.json`. `published.json` lives inside the
29
+ * block-pack directory (written there by `block-tools publish`), so for
30
+ * `from-pack-v2` we read it directly from `packUrl` (converted to a path at this
31
+ * Node boundary) — the structurer owns the layout, we do not re-derive it. For a
32
+ * `dev-v2` block folder it sits under
33
+ * `<folder>/block-pack/`. The facade's engine-generated `from-pack-v2`
34
+ * BlockPointer feeds this so a consumed block can be installed via the registry
35
+ * path. Throws when the block was never published.
36
+ */
37
+ export declare function resolveToRegistry(locator: BlockPackDevV2 | BlockPackFromPackV2): Promise<BlockPackFromRegistryV2>;
@@ -6,3 +6,15 @@ export declare function parsePackageName(packageName: string): Pick<BlockPackId,
6
6
  export declare function tryLoadPackDescription(moduleRoot: string, logger?: MiLogger): Promise<BlockPackDescriptionAbsolute | undefined>;
7
7
  export declare function loadPackDescriptionRaw(moduleRoot: string): Promise<BlockPackDescriptionRaw>;
8
8
  export declare function loadPackDescription(moduleRoot: string): Promise<BlockPackDescriptionAbsolute>;
9
+ /** Folder name (under a consumed package root) holding the v2 block-pack artifacts. */
10
+ export declare const BlockPackFolderName = "block-pack";
11
+ /**
12
+ * Loads a block-pack description from a block-pack directory's `manifest.json`,
13
+ * resolving every relative entry against that directory to an absolute
14
+ * filesystem path. Takes the pack directory directly (the `from-pack-v2`
15
+ * pointer's `packUrl`) — the structurer owns the layout, so this does not
16
+ * re-derive `<root>/block-pack`. Counterpart to `loadPackDescription`, which
17
+ * reads the authoring `block` field from `package.json` and resolves against
18
+ * source.
19
+ */
20
+ export declare function loadPackDescriptionFromManifest(blockPackDir: string): Promise<BlockPackDescriptionAbsolute>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/block-tools",
3
- "version": "2.11.3",
3
+ "version": "2.11.5",
4
4
  "description": "Utility to manipulate Platforma Blocks and Block Registry",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -33,13 +33,13 @@
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
- "@milaboratories/pl-model-middle-layer": "1.30.8",
38
- "@milaboratories/pl-model-backend": "1.4.8",
36
+ "@milaboratories/pl-model-backend": "1.4.9",
37
+ "@milaboratories/pl-model-middle-layer": "1.30.9",
39
38
  "@milaboratories/pl-model-common": "1.46.2",
40
- "@milaboratories/resolve-helper": "1.1.3",
41
39
  "@platforma-sdk/blocks-deps-updater": "2.2.0",
42
- "@milaboratories/ts-helpers": "1.8.3"
40
+ "@milaboratories/pl-http": "1.2.4",
41
+ "@milaboratories/ts-helpers": "1.8.3",
42
+ "@milaboratories/resolve-helper": "1.1.3"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@rollup/plugin-node-resolve": "^16.0.1",
@@ -55,8 +55,8 @@
55
55
  "vite-plugin-dts": "^4.5.3",
56
56
  "vitest": "^4.1.3",
57
57
  "@milaboratories/build-configs": "2.0.0",
58
- "@milaboratories/ts-configs": "1.2.3",
59
- "@milaboratories/ts-builder": "1.5.2"
58
+ "@milaboratories/ts-builder": "1.6.0",
59
+ "@milaboratories/ts-configs": "1.3.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "vite build",
@@ -0,0 +1,35 @@
1
+ import { test, expect } from "vitest";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import fsp from "node:fs/promises";
5
+ import { publishCommand } from "./publish";
6
+
7
+ function option(cmd: ReturnType<typeof publishCommand>, long: string) {
8
+ return cmd.options.find((o) => o.long === long);
9
+ }
10
+
11
+ test("publish keeps --registry required and adds a required --registry-serve-url", () => {
12
+ const cmd = publishCommand();
13
+ expect(option(cmd, "--registry")?.mandatory).toBe(true);
14
+ expect(option(cmd, "--registry-serve-url")?.mandatory).toBe(true);
15
+ });
16
+
17
+ test("publish errors when the serve URL is absent", async () => {
18
+ // A real manifest file so the action's read never runs — parsing must reject
19
+ // on the missing required --registry-serve-url before any registry I/O.
20
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "publish-flags-"));
21
+ const manifestPath = path.join(dir, "manifest.json");
22
+ await fsp.writeFile(manifestPath, "{}");
23
+
24
+ // Clear the env binding so the mandatory check, not the environment, decides.
25
+ const prev = process.env.PL_REGISTRY_SERVE_URL;
26
+ delete process.env.PL_REGISTRY_SERVE_URL;
27
+ try {
28
+ const cmd = publishCommand().exitOverride();
29
+ await expect(
30
+ cmd.parseAsync(["-r", "s3://bucket", "-m", manifestPath], { from: "user" }),
31
+ ).rejects.toThrow(/registry-serve-url/i);
32
+ } finally {
33
+ if (prev !== undefined) process.env.PL_REGISTRY_SERVE_URL = prev;
34
+ }
35
+ });
@@ -9,6 +9,7 @@ import {
9
9
  } from "@milaboratories/pl-model-middle-layer";
10
10
  import { storageByUrl } from "../io/storage";
11
11
  import { BlockRegistryV2 } from "../v2/registry/registry";
12
+ import { buildPublishedCoords, writePublishedCoords } from "../v2/resolve_to_registry";
12
13
  import path from "node:path";
13
14
 
14
15
  function simpleDeepMerge<T extends Record<string, unknown>>(
@@ -45,6 +46,14 @@ export function publishCommand(): Command {
45
46
  .env("PL_REGISTRY")
46
47
  .makeOptionMandatory(),
47
48
  );
49
+ cmd.addOption(
50
+ new Option(
51
+ "--registry-serve-url <url>",
52
+ "CDN serve URL written into block-pack/published.json (npm-consumed blocks)",
53
+ )
54
+ .env("PL_REGISTRY_SERVE_URL")
55
+ .makeOptionMandatory(),
56
+ );
48
57
  cmd.option("-m, --manifest <path>", "manifest file path", `./block-pack/${ManifestFileName}`);
49
58
  cmd.option("-v, --version-override <path>", "override package version");
50
59
  cmd.addOption(
@@ -90,6 +99,17 @@ export function publishCommand(): Command {
90
99
  await registry.addPackageToChannel(manifest.description.id, StableChannel);
91
100
  }
92
101
 
102
+ // Write registry coordinates next to the manifest so npm consumers can
103
+ // resolve the published block via `resolveToRegistry`. Written before
104
+ // returning so `npm publish`'s tarball generation (after prepublishOnly)
105
+ // picks it up.
106
+ const coords = buildPublishedCoords({
107
+ registryUrl: flags.registryServeUrl,
108
+ id: manifest.description.id,
109
+ });
110
+ await writePublishedCoords(manifestRoot, coords);
111
+ logger.info(`Wrote published coordinates to ${path.resolve(manifestRoot, "published.json")}`);
112
+
93
113
  if (flags.refresh) await registry.updateIfNeeded();
94
114
  });
95
115
 
@@ -36,10 +36,13 @@ describe("DISCOVERY: shortName from the block package name", () => {
36
36
  // Nameless root — the standalone shape; classified `root` by path.
37
37
  "package.json": "{}",
38
38
  // Legacy block package: name IS the bare facade, no `.block` suffix.
39
+ // Classified `block` via its top-level `block` field (every real block —
40
+ // legacy shim or migrated facade — carries one).
39
41
  "block/package.json": JSON.stringify({
40
42
  name: "@platforma-open/test-org.legacy",
41
43
  files: ["index.d.ts", "index.js"],
42
44
  dependencies: { "@platforma-sdk/block-tools": "*" },
45
+ block: { components: {}, meta: {} },
43
46
  }),
44
47
  });
45
48
  const ctx = discoverRunContext({ fs, isSdkInternal: false });
@@ -0,0 +1,195 @@
1
+ // Facade end-state coverage — the slim `block/` the structurer emits and
2
+ // enforces: block-scope detection by the top-level `block` field, the four-file
3
+ // `src/` surface + facade `tsconfig.json`, the slim publishable `package.json`,
4
+ // sibling privacy, and legacy-shim cleanup.
5
+
6
+ import { describe, test, expect } from "vitest";
7
+ import type { BlockVars, RunContext } from "../engine/api";
8
+ import { classifyOne, type WorkspacePackage } from "../engine/discovery";
9
+ import { STRUCTURE } from "../structure-definition";
10
+ import { run as engineRun } from "../engine/runner";
11
+ import { defaultTemplateProvider, simulateInit } from "../engine/testing";
12
+
13
+ const TEMPLATES = defaultTemplateProvider();
14
+
15
+ const VARS: BlockVars = {
16
+ facadeName: "@platforma-open/test-org.demo",
17
+ baseName: "test-org.demo",
18
+ npmOrg: "@platforma-open",
19
+ orgScope: "test-org",
20
+ shortName: "demo",
21
+ };
22
+
23
+ function pkg(path: string, pkgJson: Record<string, unknown>): WorkspacePackage {
24
+ return { path, pkg: pkgJson };
25
+ }
26
+
27
+ describe("facade: block-scope detection by the top-level `block` field", () => {
28
+ test("a package with a top-level `block` field is classified `block`", () => {
29
+ expect(classifyOne(pkg("block", { name: "x", block: { components: {} } }))).toBe("block");
30
+ });
31
+
32
+ test("the `block` field wins over the model shape (the collision the order guards)", () => {
33
+ // A migrated facade carries `@platforma-sdk/model` (devDep) + `main`, which
34
+ // matches the model rule — the `block` field must take precedence.
35
+ expect(
36
+ classifyOne(
37
+ pkg("block", {
38
+ name: "x",
39
+ main: "./dist/index.js",
40
+ devDependencies: { "@platforma-sdk/model": "catalog:" },
41
+ block: { components: {} },
42
+ }),
43
+ ),
44
+ ).toBe("block");
45
+ });
46
+
47
+ test("a model-shaped package WITHOUT a `block` field is still `model`, not `block`", () => {
48
+ expect(
49
+ classifyOne(
50
+ pkg("model", {
51
+ name: "x.model",
52
+ main: "dist/index.cjs",
53
+ dependencies: { "@platforma-sdk/model": "catalog:" },
54
+ }),
55
+ ),
56
+ ).toBe("model");
57
+ });
58
+ });
59
+
60
+ describe("facade: four-file source surface + tsconfig", () => {
61
+ test("init emits the 4 src files + tsconfig, and no legacy shim", () => {
62
+ const { fs } = simulateInit({ vars: VARS });
63
+ for (const f of [
64
+ "block/src/index.ts",
65
+ "block/src/AGENTS.ts",
66
+ "block/src/block-extra.ts",
67
+ "block/src/agents-extra.ts",
68
+ "block/tsconfig.json",
69
+ ]) {
70
+ expect(fs.exists(f), `expected ${f} to be emitted`).toBe(true);
71
+ }
72
+ expect(fs.exists("block/index.js")).toBe(false);
73
+ expect(fs.exists("block/index.d.ts")).toBe(false);
74
+ });
75
+
76
+ test("index.ts carries the per-block model import path and PascalName aliases", () => {
77
+ const { fs } = simulateInit({ vars: VARS });
78
+ const index = fs.read("block/src/index.ts");
79
+ expect(index).toContain('import { platforma } from "@platforma-open/test-org.demo.model"');
80
+ expect(index).toContain("export type DemoBlockContract = BlockContract;");
81
+ expect(index).toContain("export const DemoBlockPointer = BlockPointer;");
82
+ expect(index).toContain('export * from "./block-extra"');
83
+ });
84
+
85
+ test("tsconfig extends the facade preset (singular `block/facade`)", () => {
86
+ const { fs } = simulateInit({ vars: VARS });
87
+ const tsconfig = JSON.parse(fs.read("block/tsconfig.json"));
88
+ expect(tsconfig.extends).toBe("@milaboratories/ts-configs/block/facade");
89
+ });
90
+ });
91
+
92
+ describe("facade: slim `package.json` end-state", () => {
93
+ test("is slim and carries the facade fields, scripts, and sibling devDeps", () => {
94
+ const { fs } = simulateInit({ vars: VARS });
95
+ const p = JSON.parse(fs.read("block/package.json"));
96
+
97
+ // Slim invariant: zero runtime dependencies.
98
+ expect(p.dependencies).toEqual({});
99
+
100
+ expect(p.type).toBe("module");
101
+ expect(p.files).toEqual(["dist", "block-pack"]);
102
+ expect(p.main).toBe("./dist/index.js");
103
+ expect(p.module).toBe("./dist/index.js");
104
+ expect(p.types).toBe("./dist/index.d.ts");
105
+ expect(p.exports).toEqual({
106
+ ".": {
107
+ sources: "./src/index.ts",
108
+ types: "./dist/index.d.ts",
109
+ default: "./dist/index.js",
110
+ },
111
+ });
112
+
113
+ expect(Object.keys(p.scripts).sort()).toEqual(["build", "check", "do-pack", "prepublishOnly"]);
114
+ expect(p.scripts.build).toBe("ts-builder build --target block-facade && block-tools pack");
115
+ expect(p.scripts.check).toBe("ts-builder type-check --target block-facade");
116
+
117
+ // Siblings are build-time devDeps (workspace links), never runtime deps.
118
+ expect(p.devDependencies["@platforma-open/test-org.demo.model"]).toBe("workspace:*");
119
+ expect(p.devDependencies["@platforma-open/test-org.demo.ui"]).toBe("workspace:*");
120
+ expect(p.devDependencies["@platforma-open/test-org.demo.workflow"]).toBe("workspace:*");
121
+
122
+ // Author-owned seed + engine-managed components both present.
123
+ expect(p.block.components).toBeTruthy();
124
+ expect(p.block.meta).toBeTruthy();
125
+ });
126
+ });
127
+
128
+ describe("facade: `prepublishOnly` publish target branches on isSdkInternal", () => {
129
+ const AUX_DEV =
130
+ "block-tools publish -r s3://milab-euce1-prod-pkgs-s3-block-registry/aux/dev/?region=eu-central-1 --registry-serve-url https://aux-blocks.pl-open.science";
131
+
132
+ test("in-SDK blocks publish to the aux/dev registry, regardless of org", () => {
133
+ // `@milaboratories` would route to production via ORG_PUBLISH_TARGETS — the
134
+ // isSdkInternal branch must override that and send it to aux/dev.
135
+ const vars: BlockVars = {
136
+ facadeName: "@milaboratories/test-org.demo",
137
+ baseName: "test-org.demo",
138
+ npmOrg: "@milaboratories",
139
+ orgScope: "test-org",
140
+ shortName: "demo",
141
+ };
142
+ const { fs } = simulateInit({ vars, isSdkInternal: true });
143
+ const p = JSON.parse(fs.read("block/package.json"));
144
+ expect(p.scripts.prepublishOnly).toBe(AUX_DEV);
145
+ });
146
+
147
+ test("standalone blocks publish to their per-org production target", () => {
148
+ const open = simulateInit({ vars: VARS });
149
+ expect(JSON.parse(open.fs.read("block/package.json")).scripts.prepublishOnly).toBe(
150
+ "block-tools publish -r s3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1 --registry-serve-url https://blocks.pl-open.science",
151
+ );
152
+
153
+ const milab = simulateInit({
154
+ vars: {
155
+ facadeName: "@milaboratories/test-org.demo",
156
+ baseName: "test-org.demo",
157
+ npmOrg: "@milaboratories",
158
+ orgScope: "test-org",
159
+ shortName: "demo",
160
+ },
161
+ });
162
+ expect(JSON.parse(milab.fs.read("block/package.json")).scripts.prepublishOnly).toBe(
163
+ "block-tools publish -r s3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1 --registry-serve-url https://block.registry.platforma.bio/releases",
164
+ );
165
+ });
166
+ });
167
+
168
+ describe("facade: sibling package privacy", () => {
169
+ test("model / ui / workflow / test are `private` with no `version`", () => {
170
+ const { fs } = simulateInit({ vars: VARS });
171
+ for (const scope of ["model", "ui", "workflow", "test"]) {
172
+ const p = JSON.parse(fs.read(`${scope}/package.json`));
173
+ expect(p.private, `${scope} must be private`).toBe(true);
174
+ expect("version" in p, `${scope} must not carry a version`).toBe(false);
175
+ }
176
+ });
177
+ });
178
+
179
+ describe("facade: legacy shim cleanup on refresh", () => {
180
+ test("a refresh removes a pre-existing block/index.js + index.d.ts, then is idempotent", () => {
181
+ const { fs, ctx } = simulateInit({ vars: VARS });
182
+ // Inject the legacy boilerplate shim a pre-facade block would carry.
183
+ fs.write("block/index.js", "module.exports = { blockSpec: {} };\n");
184
+ fs.write("block/index.d.ts", "export declare const blockSpec: unknown;\n");
185
+
186
+ // Default-mode refresh (not dry-run): the engine's post-run recheck throws
187
+ // RecheckError if a second pass would change anything — so this asserts
188
+ // convergence too.
189
+ const refreshCtx: RunContext = { ...ctx, dryRun: false };
190
+ expect(() => engineRun(STRUCTURE, fs, refreshCtx, { templates: TEMPLATES })).not.toThrow();
191
+
192
+ expect(fs.exists("block/index.js")).toBe(false);
193
+ expect(fs.exists("block/index.d.ts")).toBe(false);
194
+ });
195
+ });
@@ -9,7 +9,7 @@ import type { BlockVars } from "../engine/api";
9
9
 
10
10
  const EXPECTED_MODEL_PACKAGE_JSON = `{
11
11
  "name": "@platforma-open/test-org.demo.model",
12
- "version": "1.0.0",
12
+ "private": true,
13
13
  "type": "module",
14
14
  "main": "dist/index.cjs",
15
15
  "module": "dist/index.js",
@@ -1,5 +1,5 @@
1
- // Workspace-package classification — covers all 7 detection rules
2
- // plus the block-scope override and the unclassified-error path.
1
+ // Workspace-package classification — covers the detection rules plus the
2
+ // block-scope override and the unclassified-error path.
3
3
 
4
4
  import { describe, test, expect } from "vitest";
5
5
  import { classifyOne, discoverModules, DiscoveryError, type WorkspacePackage } from "../discovery";
@@ -14,7 +14,7 @@ describe("discovery — single-package classification", () => {
14
14
  name: "x.software",
15
15
  "block-software": {},
16
16
  });
17
- expect(classifyOne(wp, new Set())).toBe("software");
17
+ expect(classifyOne(wp)).toBe("software");
18
18
  });
19
19
 
20
20
  test("rule 1 alt: software via package-builder + runenv dep", () => {
@@ -25,67 +25,62 @@ describe("discovery — single-package classification", () => {
25
25
  "@platforma-open/runenv-python-3": "catalog:",
26
26
  },
27
27
  });
28
- expect(classifyOne(wp, new Set())).toBe("software");
28
+ expect(classifyOne(wp)).toBe("software");
29
29
  });
30
30
 
31
- test("rule 2: workflow via workflow-tengo dep", () => {
31
+ test("workflow via workflow-tengo dep", () => {
32
32
  const wp = pkg("workflow", {
33
33
  name: "x.workflow",
34
34
  dependencies: { "@platforma-sdk/workflow-tengo": "catalog:" },
35
35
  });
36
- expect(classifyOne(wp, new Set())).toBe("workflow");
36
+ expect(classifyOne(wp)).toBe("workflow");
37
37
  });
38
38
 
39
- test("rule 3: ui via ui-vue dep and no main", () => {
39
+ test("ui via ui-vue dep and no main", () => {
40
40
  const wp = pkg("ui", {
41
41
  name: "x.ui",
42
42
  dependencies: { "@platforma-sdk/ui-vue": "catalog:" },
43
43
  });
44
- expect(classifyOne(wp, new Set())).toBe("ui");
44
+ expect(classifyOne(wp)).toBe("ui");
45
45
  });
46
46
 
47
- test("rule 4: block via block-tools + sibling model/ui/workflow", () => {
48
- const siblings = new Set(["x.model", "x.ui", "x.workflow"]);
47
+ test("block via the top-level `block` field", () => {
49
48
  const wp = pkg("block", {
50
49
  name: "x.block",
51
- dependencies: {
52
- "@platforma-sdk/block-tools": "catalog:",
53
- "x.model": "workspace:*",
54
- "x.ui": "workspace:*",
55
- },
50
+ block: { components: {}, meta: {} },
56
51
  });
57
- expect(classifyOne(wp, siblings)).toBe("block");
52
+ expect(classifyOne(wp)).toBe("block");
58
53
  });
59
54
 
60
- test("rule 4 fallback: files=[index.d.ts,index.js] + block-tools", () => {
55
+ test("a block-tools-ish package WITHOUT a `block` field is not classified `block`", () => {
61
56
  const wp = pkg("block", {
62
57
  name: "x.block",
63
58
  files: ["index.d.ts", "index.js"],
64
59
  dependencies: { "@platforma-sdk/block-tools": "catalog:" },
65
60
  });
66
- expect(classifyOne(wp, new Set())).toBe("block");
61
+ expect(classifyOne(wp)).toBeUndefined();
67
62
  });
68
63
 
69
- test("rule 5: model via model dep + main", () => {
64
+ test("model via model dep + main", () => {
70
65
  const wp = pkg("model", {
71
66
  name: "x.model",
72
67
  main: "dist/index.js",
73
68
  dependencies: { "@platforma-sdk/model": "catalog:" },
74
69
  });
75
- expect(classifyOne(wp, new Set())).toBe("model");
70
+ expect(classifyOne(wp)).toBe("model");
76
71
  });
77
72
 
78
- test("rule 6: test via @platforma-sdk/test dep", () => {
73
+ test("test via @platforma-sdk/test dep", () => {
79
74
  const wp = pkg("test", {
80
75
  name: "x.test",
81
76
  devDependencies: { "@platforma-sdk/test": "catalog:" },
82
77
  });
83
- expect(classifyOne(wp, new Set())).toBe("test");
78
+ expect(classifyOne(wp)).toBe("test");
84
79
  });
85
80
 
86
- test("rule 7: root as catch-all", () => {
81
+ test("root as catch-all", () => {
87
82
  const wp = pkg("", { name: "x" });
88
- expect(classifyOne(wp, new Set())).toBe("root");
83
+ expect(classifyOne(wp)).toBe("root");
89
84
  });
90
85
 
91
86
  test("override: block-scope wins over detection", () => {
@@ -94,7 +89,7 @@ describe("discovery — single-package classification", () => {
94
89
  "block-scope": "software",
95
90
  dependencies: { "@platforma-sdk/workflow-tengo": "catalog:" },
96
91
  });
97
- expect(classifyOne(wp, new Set())).toBe("software");
92
+ expect(classifyOne(wp)).toBe("software");
98
93
  });
99
94
 
100
95
  test("override: bad value throws DiscoveryError", () => {
@@ -102,7 +97,7 @@ describe("discovery — single-package classification", () => {
102
97
  name: "x",
103
98
  "block-scope": "nope",
104
99
  });
105
- expect(() => classifyOne(wp, new Set())).toThrow(DiscoveryError);
100
+ expect(() => classifyOne(wp)).toThrow(DiscoveryError);
106
101
  });
107
102
 
108
103
  test("unclassified non-root throws DiscoveryError", () => {
@@ -134,6 +129,7 @@ describe("discovery — full workspace", () => {
134
129
  }),
135
130
  pkg("block", {
136
131
  name: "x.block",
132
+ block: { components: {}, meta: {} },
137
133
  dependencies: {
138
134
  "@platforma-sdk/block-tools": "catalog:",
139
135
  "x.model": "workspace:*",
@@ -25,6 +25,8 @@ export type PackageJsonLike = {
25
25
  devDependencies?: Record<string, string>;
26
26
  peerDependencies?: Record<string, string>;
27
27
  optionalDependencies?: Record<string, string>;
28
+ // Top-level `block` field — the unambiguous facade marker.
29
+ block?: unknown;
28
30
  // Override field — value MUST be one of the seven scopes.
29
31
  "block-scope"?: string;
30
32
  "block-software"?: unknown;
@@ -61,11 +63,9 @@ function isRoot(p: string): boolean {
61
63
  * Classify one package.
62
64
  *
63
65
  * @param wp The workspace package.
64
- * @param siblingNames Set of all workspace package `name`s (used by
65
- * rule 4 "block": depends on ≥2 sibling .model/.ui/.workflow).
66
66
  * @returns Scope or `undefined` if unclassified.
67
67
  */
68
- export function classifyOne(wp: WorkspacePackage, siblingNames: Set<string>): Scope | undefined {
68
+ export function classifyOne(wp: WorkspacePackage): Scope | undefined {
69
69
  const override = wp.pkg["block-scope"];
70
70
  if (typeof override === "string") {
71
71
  if (!(ALL_SCOPES as string[]).includes(override)) {
@@ -88,6 +88,12 @@ export function classifyOne(wp: WorkspacePackage, siblingNames: Set<string>): Sc
88
88
  // sub-package dirs), so this only ever fires in standalone mode.
89
89
  if (isRoot(wp.path)) return "root";
90
90
 
91
+ // block (facade) — a top-level `block` field is the unambiguous facade
92
+ // marker; no non-facade workspace package carries one. MUST precede the model
93
+ // rule: a migrated facade carries `@platforma-sdk/model` as a devDep plus
94
+ // `main`, which would otherwise match model.
95
+ if (wp.pkg.block !== undefined) return "block";
96
+
91
97
  const deps = depKeys(wp.pkg);
92
98
 
93
99
  // 1. software — top-level `block-software` field OR
@@ -107,30 +113,10 @@ export function classifyOne(wp: WorkspacePackage, siblingNames: Set<string>): Sc
107
113
  return "ui";
108
114
  }
109
115
 
110
- // 4. block — block-tools in deps AND ≥2 sibling .model/.ui/.workflow.
111
- if (deps.has("@platforma-sdk/block-tools")) {
112
- const matches = [...deps].filter(
113
- (d) =>
114
- siblingNames.has(d) &&
115
- (d.endsWith(".model") || d.endsWith(".ui") || d.endsWith(".workflow")),
116
- );
117
- if (matches.length >= 2) return "block";
118
- // Fallback: `files == ["index.d.ts", "index.js"]` + block-tools.
119
- const files = wp.pkg.files;
120
- if (
121
- Array.isArray(files) &&
122
- files.length === 2 &&
123
- files.includes("index.d.ts") &&
124
- files.includes("index.js")
125
- ) {
126
- return "block";
127
- }
128
- }
129
-
130
- // 5. model — @platforma-sdk/model in deps AND main is set.
116
+ // 4. model — @platforma-sdk/model in deps AND main is set.
131
117
  if (deps.has("@platforma-sdk/model") && wp.pkg.main) return "model";
132
118
 
133
- // 6. test — @platforma-sdk/test in deps.
119
+ // 5. test — @platforma-sdk/test in deps.
134
120
  if (deps.has("@platforma-sdk/test")) return "test";
135
121
 
136
122
  // (root — the workspace root, path "", is classified above, before the
@@ -144,13 +130,9 @@ export function classifyOne(wp: WorkspacePackage, siblingNames: Set<string>): Sc
144
130
  * package.json.
145
131
  */
146
132
  export function discoverModules(packages: WorkspacePackage[]): Module[] {
147
- const siblingNames = new Set<string>();
148
- for (const p of packages) {
149
- if (typeof p.pkg.name === "string") siblingNames.add(p.pkg.name);
150
- }
151
133
  const out: Module[] = [];
152
134
  for (const p of packages) {
153
- const scope = classifyOne(p, siblingNames);
135
+ const scope = classifyOne(p);
154
136
  if (!scope) {
155
137
  throw new DiscoveryError(
156
138
  `Unclassified workspace package at '${p.path}'. ` +