@platforma-sdk/block-tools 2.13.0 → 2.14.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 (69) hide show
  1. package/dist/cli.js +12 -12
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +728 -648
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/cmd/build-kind-manifest.d.ts +7 -0
  6. package/dist/config-CtXMWNgm.js +3 -0
  7. package/dist/config-CtXMWNgm.js.map +1 -0
  8. package/dist/{config-CiC347sB.mjs → config-Dx45aFJ_.mjs} +792 -456
  9. package/dist/config-Dx45aFJ_.mjs.map +1 -0
  10. package/dist/index.js +1 -1
  11. package/dist/index.mjs +15 -15
  12. package/dist/structure/engine/api.d.ts +1 -1
  13. package/dist/structure/rules/kind-package-json.d.ts +3 -0
  14. package/dist/structure/rules/kind.d.ts +1 -0
  15. package/dist/v2/build_kind_dist.d.ts +25 -0
  16. package/dist/v2/index.d.ts +4 -0
  17. package/dist/v2/kind/resolve-refs.d.ts +63 -0
  18. package/dist/v2/kind/version-match.d.ts +23 -0
  19. package/dist/v2/model/block_description.d.ts +8 -1
  20. package/dist/v2/publish-block.d.ts +23 -0
  21. package/dist/v2/registry/index.d.ts +2 -0
  22. package/dist/v2/registry/kind_resolver.d.ts +121 -0
  23. package/dist/v2/registry/registry.d.ts +23 -0
  24. package/dist/v2/registry/registry_reader.d.ts +34 -1
  25. package/dist/v2/registry/schema_kinds.d.ts +632 -0
  26. package/dist/v2/registry/schema_public.d.ts +72 -0
  27. package/package.json +7 -7
  28. package/src/cli.test.ts +2 -1
  29. package/src/cli.ts +2 -0
  30. package/src/cmd/build-kind-manifest.ts +41 -0
  31. package/src/cmd/publish.ts +10 -1
  32. package/src/structure/__tests__/model-package-json.snapshot.test.ts +1 -0
  33. package/src/structure/engine/api.ts +1 -1
  34. package/src/structure/engine/ctx.ts +1 -0
  35. package/src/structure/engine/discovery.ts +16 -2
  36. package/src/structure/rules/block-package-json.ts +18 -0
  37. package/src/structure/rules/kind-package-json.ts +117 -0
  38. package/src/structure/rules/kind.ts +29 -0
  39. package/src/structure/rules/model-package-json.ts +8 -0
  40. package/src/structure/rules/model.ts +15 -13
  41. package/src/structure/rules/root-pnpm-workspace.ts +1 -0
  42. package/src/structure/rules/shared/pascal-case.ts +1 -1
  43. package/src/structure/structure-definition.ts +2 -0
  44. package/src/structure/templates/static/kind/.oxfmtrc.json +4 -0
  45. package/src/structure/templates/static/kind/.oxlintrc.json +3 -0
  46. package/src/structure/templates/static/kind/src/index.ts +49 -0
  47. package/src/structure/templates/static/kind/tsconfig.json +10 -0
  48. package/src/structure/templates/text/model/src/index.tpl.ts +18 -0
  49. package/src/v2/build_dist.ts +35 -0
  50. package/src/v2/build_kind_dist.ts +104 -0
  51. package/src/v2/from_pack_v2.test.ts +2 -2
  52. package/src/v2/index.ts +4 -0
  53. package/src/v2/kind/resolve-refs.ts +150 -0
  54. package/src/v2/kind/version-match.test.ts +60 -0
  55. package/src/v2/kind/version-match.ts +55 -0
  56. package/src/v2/model/block_description.ts +15 -4
  57. package/src/v2/publish-block.ts +60 -0
  58. package/src/v2/registry/index.ts +2 -0
  59. package/src/v2/registry/kind_resolver.test.ts +123 -0
  60. package/src/v2/registry/kind_resolver.ts +190 -0
  61. package/src/v2/registry/registry.test.ts +247 -1
  62. package/src/v2/registry/registry.ts +227 -4
  63. package/src/v2/registry/registry_reader.ts +68 -0
  64. package/src/v2/registry/schema_kinds.test.ts +65 -0
  65. package/src/v2/registry/schema_kinds.ts +169 -0
  66. package/dist/config-BmJWc8sR.js +0 -3
  67. package/dist/config-BmJWc8sR.js.map +0 -1
  68. package/dist/config-CiC347sB.mjs.map +0 -1
  69. package/src/structure/templates/static/model/src/index.ts +0 -13
@@ -0,0 +1,104 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { util } from "@platforma-sdk/package-builder-lib";
4
+ import { calculateSha256 } from "../util";
5
+ import type { KindManifest, KindManifestFileInfo } from "./registry/schema_kinds";
6
+
7
+ export interface BuildKindDistOptions {
8
+ /** Kind package directory (the dir containing `package.json`, `src/`, `dist/`). */
9
+ modulePath?: string;
10
+ /** Source directory hashed into `sourceHash`, relative to `modulePath`. */
11
+ srcDir?: string;
12
+ /** Output directory holding the compiled bundle and receiving `manifest.json`. */
13
+ dst?: string;
14
+ /** Compiled entry file name inside `dst` (rolldown emits `kind.js`). */
15
+ entryFileName?: string;
16
+ }
17
+
18
+ // The on-wire kind manifest shape (identity, per-file info, and the manifest
19
+ // itself) is owned by the registry schema module — the single source of truth
20
+ // shared by the build side (this producer) and the registry read/write path.
21
+ export type {
22
+ KindManifest,
23
+ KindManifestIdentity,
24
+ KindManifestFileInfo,
25
+ } from "./registry/schema_kinds";
26
+
27
+ export const KindManifestFile = "manifest.json";
28
+
29
+ /**
30
+ * Read the kind's identity from its own `package.json` — the SINGLE source of
31
+ * truth. The block-kind build bakes the SAME `{name, version}` into the emitted
32
+ * bundle (via `define`), so the manifest identity here and the runtime
33
+ * descriptor's identity are guaranteed to agree. Reading package.json directly
34
+ * (rather than importing the ESM bundle) keeps this authoritative and free of a
35
+ * dynamic-import dependency on the compiled output.
36
+ */
37
+ async function readKindPackageIdentity(
38
+ modulePath: string,
39
+ ): Promise<{ name: string; version: string }> {
40
+ const pkgPath = path.resolve(modulePath, "package.json");
41
+ let raw: string;
42
+ try {
43
+ raw = await fsp.readFile(pkgPath, "utf-8");
44
+ } catch {
45
+ throw new Error(`Cannot read kind package.json at ${pkgPath}.`);
46
+ }
47
+ const pkg = JSON.parse(raw) as { name?: unknown; version?: unknown };
48
+ if (typeof pkg.name !== "string" || typeof pkg.version !== "string") {
49
+ throw new Error(`Kind package.json at ${pkgPath} must declare string "name" and "version".`);
50
+ }
51
+ return { name: pkg.name, version: pkg.version };
52
+ }
53
+
54
+ /**
55
+ * Commander-free core: bundle-in, manifest-out. Reads the kind's npm package
56
+ * `name`/`version` from its `package.json`, computes one sha256 over the `src/`
57
+ * tree (upper-case), and writes `manifest.json` LAST as the commit marker (the
58
+ * build_dist.ts convention: readers treat the manifest's presence as "the dist
59
+ * is complete").
60
+ *
61
+ * Shaped commander-free so the deferred publish-time source-hash guard can
62
+ * `import` and reuse both the hash computation and this manifest shape without
63
+ * CLI coupling.
64
+ */
65
+ export async function buildKindDist(opts: BuildKindDistOptions = {}): Promise<KindManifest> {
66
+ const modulePath = path.resolve(opts.modulePath ?? ".");
67
+ const srcDir = path.resolve(modulePath, opts.srcDir ?? "src");
68
+ const dst = path.resolve(modulePath, opts.dst ?? "dist");
69
+ const entryFileName = opts.entryFileName ?? "kind.js";
70
+
71
+ const { name, version } = await readKindPackageIdentity(modulePath);
72
+
73
+ const sourceHash = util.hashDirSync(srcDir).digest("hex").toUpperCase();
74
+
75
+ const artifactNames = [entryFileName, "kind.d.ts"];
76
+ const files: KindManifestFileInfo[] = [];
77
+ for (const artifact of artifactNames) {
78
+ const abs = path.resolve(dst, artifact);
79
+ let bytes: Buffer;
80
+ try {
81
+ bytes = await fsp.readFile(abs);
82
+ } catch (err: unknown) {
83
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
84
+ throw new Error(
85
+ `Kind build is incomplete: ${artifact} is missing from ${dst}. Run the kind's build before building its manifest.`,
86
+ );
87
+ }
88
+ throw err;
89
+ }
90
+ files.push({ name: artifact, size: bytes.length, sha256: await calculateSha256(bytes) });
91
+ }
92
+
93
+ const manifest: KindManifest = {
94
+ schema: "v1",
95
+ kind: { name, version },
96
+ sourceHash,
97
+ files,
98
+ timestamp: Date.now(),
99
+ };
100
+
101
+ // manifest.json written LAST — commit marker.
102
+ await fsp.writeFile(path.resolve(dst, KindManifestFile), JSON.stringify(manifest));
103
+ return manifest;
104
+ }
@@ -80,12 +80,12 @@ test("loadPackDescriptionFromManifest resolves manifest entries to absolute FS p
80
80
  expect(existsSync(description.components.ui.folder)).toBe(true);
81
81
  });
82
82
 
83
- test("loadPackDescriptionFromManifest works against a real packed sum-numbers-v3", async () => {
83
+ test("loadPackDescriptionFromManifest works against a real packed sum-numbers", async () => {
84
84
  // Runs when the block has been built+packed; skips gracefully in a clean
85
85
  // checkout where block-pack/ is gitignored. Guards against manifest-shape
86
86
  // drift the hermetic fixture above can't catch.
87
87
  const here = path.dirname(fileURLToPath(import.meta.url));
88
- const real = path.resolve(here, "../../../../etc/blocks/sum-numbers-v3/block");
88
+ const real = path.resolve(here, "../../../../etc/blocks/sum-numbers/block");
89
89
  if (!existsSync(path.join(real, "block-pack", "manifest.json"))) return;
90
90
 
91
91
  const description = await loadPackDescriptionFromManifest(path.join(real, "block-pack"));
package/src/v2/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export * from "./model";
2
2
  export * from "./build_dist";
3
+ export * from "./build_kind_dist";
4
+ export * from "./publish-block";
5
+ export * from "./kind/resolve-refs";
6
+ export * from "./kind/version-match";
3
7
  export * from "./source_package";
4
8
  export * from "./resolve_to_registry";
5
9
  export * from "./registry";
@@ -0,0 +1,150 @@
1
+ import fsp from "node:fs/promises";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { formatKindRef, type BlockKindReference } from "@milaboratories/pl-model-common";
6
+ import type { BlockPackManifest } from "@milaboratories/pl-model-middle-layer";
7
+ import { KindManifest } from "../registry/schema_kinds";
8
+ import type { RelativeContentReader } from "../model";
9
+
10
+ /**
11
+ * Kind → block ref resolution. The single reader of the facade's kind dependency.
12
+ *
13
+ * A kind is a fourth block component alongside model, ui and workflow, and the facade
14
+ * depends on it DIRECTLY — not transitively through one of the other three. Its concrete
15
+ * version is not read from that dependency either: the facade's range may be
16
+ * `workspace:*` or `catalog:`, so the authority is the kind's own built manifest.
17
+ *
18
+ * Keeping every "how the facade names and locates its kind" assumption in this one module
19
+ * means the publish gate and the orchestrator never touch dependency-shape details.
20
+ */
21
+
22
+ /** npm package-name suffix marking a package as a block kind (schema_kinds convention). */
23
+ const KIND_PACKAGE_SUFFIX = ".kind";
24
+
25
+ /**
26
+ * The kind reference the MODEL was compiled against, as baked into the block manifest
27
+ * when the block was built.
28
+ *
29
+ * It sits at the TOP LEVEL of the description — `description.kind`, a `{name}@{version}`
30
+ * string (declared in `block_description.ts`, read back by the registry's reconciler) —
31
+ * rather than under the model component, because the build lifts the model's
32
+ * container-level `kind` up when it writes the manifest. `undefined` means the block
33
+ * declares no kind.
34
+ */
35
+ export function readModelCompiledKindRef(
36
+ manifest: BlockPackManifest,
37
+ ): BlockKindReference | undefined {
38
+ return manifest.description.kind;
39
+ }
40
+
41
+ /** A kind dependency as declared in the facade's `package.json`. */
42
+ export interface FacadeKindDependency {
43
+ /** npm package name of the kind (ends in `.kind`). */
44
+ npmName: string;
45
+ /** Raw version range as written in `package.json` (e.g. `^1.2.3`, `workspace:*`). */
46
+ range: string;
47
+ }
48
+
49
+ /**
50
+ * The facade-side kind dependency, read from the facade's `package.json`.
51
+ *
52
+ * The facade depends on its kind DIRECTLY: the `.kind`-suffixed entry is a direct
53
+ * dependency of the facade, not something reached transitively through
54
+ * `model/package.json`. We scan both `dependencies` and
55
+ * `devDependencies` for the single `.kind` entry — the slim facade keeps the
56
+ * kind (like its model/ui/workflow siblings) as a build-time `workspace:*`
57
+ * devDep, and its concrete version is resolved from the kind's built manifest
58
+ * downstream, so the range written here (`workspace:*`/`catalog:`/a semver
59
+ * range) is not load-bearing.
60
+ *
61
+ * Returns `undefined` when no kind dependency is declared.
62
+ */
63
+ export function readFacadeKindDependency(facadeDir: string): FacadeKindDependency | undefined {
64
+ const pkgPath = path.join(facadeDir, "package.json");
65
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
66
+ dependencies?: Record<string, string>;
67
+ devDependencies?: Record<string, string>;
68
+ };
69
+ const deps = { ...pkg.devDependencies, ...pkg.dependencies };
70
+ const entry = Object.entries(deps).find(([name]) => name.endsWith(KIND_PACKAGE_SUFFIX));
71
+ if (!entry) return undefined;
72
+ return { npmName: entry[0], range: entry[1] };
73
+ }
74
+
75
+ /**
76
+ * Resolved, publishable kind artifacts: the concrete reference, the kind's
77
+ * content manifest, and a reader rooted at the kind's `dist/` folder.
78
+ */
79
+ export interface ResolvedFacadeKind {
80
+ /** Concrete `{name}@{version}` reference of the kind the facade actually ships. */
81
+ ref: BlockKindReference;
82
+ /** The kind content manifest (`build_kind_dist` output) read from the kind's dist. */
83
+ manifest: KindManifest;
84
+ /** Reads the manifest's `files[].name` relative to the kind's `dist/`. */
85
+ fileReader: RelativeContentReader;
86
+ }
87
+
88
+ /**
89
+ * Resolve the facade's kind dependency to concrete publishable artifacts.
90
+ *
91
+ * Resolves the kind npm package from the facade's perspective, reads its
92
+ * built `dist/manifest.json` (the authoritative concrete version — the facade
93
+ * `package.json` range may be `workspace:*`/`catalog:` and carry no concrete
94
+ * version), and returns a `dist/`-rooted file reader for `publishKind`.
95
+ *
96
+ * The concrete version comes from the built manifest, so a `workspace:*`/`catalog:`
97
+ * range in the facade's `package.json` resolves cleanly. Resolution uses standard node resolution from the facade dir; if the
98
+ * package or its manifest cannot be found it throws with a pointed message
99
+ * rather than silently skipping.
100
+ */
101
+ export async function resolveFacadeKind(
102
+ facadeDir: string,
103
+ npmName: string,
104
+ ): Promise<ResolvedFacadeKind> {
105
+ const kindPkgDir = resolveKindPackageDir(facadeDir, npmName);
106
+ const distDir = path.join(kindPkgDir, "dist");
107
+
108
+ let manifestRaw: string;
109
+ try {
110
+ manifestRaw = await fsp.readFile(path.join(distDir, "manifest.json"), "utf-8");
111
+ } catch {
112
+ throw new Error(
113
+ `Kind package "${npmName}" resolved to ${kindPkgDir} but has no dist/manifest.json. ` +
114
+ `Build the kind (ts-builder build --target block-kind && block-tools build-kind-manifest) before publishing.`,
115
+ );
116
+ }
117
+ const manifest = KindManifest.parse(JSON.parse(manifestRaw));
118
+
119
+ const ref = formatKindRef({ name: manifest.kind.name, version: manifest.kind.version });
120
+ const fileReader: RelativeContentReader = async (relativePath) =>
121
+ Buffer.from(await fsp.readFile(path.resolve(distDir, relativePath)));
122
+
123
+ return { ref, manifest, fileReader };
124
+ }
125
+
126
+ /** Resolve the directory of an installed kind npm package from the facade's location. */
127
+ function resolveKindPackageDir(facadeDir: string, npmName: string): string {
128
+ const req = createRequire(path.join(facadeDir, "package.json"));
129
+ // Prefer the package's own package.json (deterministic package root).
130
+ try {
131
+ return path.dirname(req.resolve(`${npmName}/package.json`));
132
+ } catch {
133
+ // Fall back to the package entry point, then walk up to the folder holding package.json.
134
+ try {
135
+ let dir = path.dirname(req.resolve(npmName));
136
+ for (let i = 0; i < 6; i++) {
137
+ if (fs.existsSync(path.join(dir, "package.json"))) return dir;
138
+ const parent = path.dirname(dir);
139
+ if (parent === dir) break;
140
+ dir = parent;
141
+ }
142
+ } catch {
143
+ /* fall through to the throw below */
144
+ }
145
+ throw new Error(
146
+ `Cannot resolve kind package "${npmName}" from ${facadeDir}. ` +
147
+ `Ensure it is installed as a dependency of the facade.`,
148
+ );
149
+ }
150
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { formatKindRef } from "@milaboratories/pl-model-common";
3
+ import { checkKindVersionMatch, KindVersionMismatchError } from "./version-match";
4
+
5
+ /**
6
+ * `checkKindVersionMatch` is the pure pre-publish gate: the model's
7
+ * compiled-against kind and the facade's declared kind must be the SAME kind at
8
+ * the SAME version. Success is "did not throw". The full `{org, name}` identity is
9
+ * compared, not just the terminal name.
10
+ */
11
+ describe("checkKindVersionMatch", () => {
12
+ const name = "@platforma-open/milaboratories.demo.kind";
13
+
14
+ test("identical name + version passes", () => {
15
+ expect(() =>
16
+ checkKindVersionMatch(
17
+ formatKindRef({ name, version: "1.2.3" }),
18
+ formatKindRef({ name, version: "1.2.3" }),
19
+ ),
20
+ ).not.toThrow();
21
+ });
22
+
23
+ test("same kind, different version -> version mismatch", () => {
24
+ expect(() =>
25
+ checkKindVersionMatch(
26
+ formatKindRef({ name, version: "1.2.3" }),
27
+ formatKindRef({ name, version: "1.2.4" }),
28
+ ),
29
+ ).toThrow(KindVersionMismatchError);
30
+ });
31
+
32
+ test("different name segment at the same version -> name mismatch", () => {
33
+ expect(() =>
34
+ checkKindVersionMatch(
35
+ formatKindRef({ name: "@platforma-open/milaboratories.demo.kind", version: "1.2.3" }),
36
+ formatKindRef({ name: "@platforma-open/milaboratories.other.kind", version: "1.2.3" }),
37
+ ),
38
+ ).toThrow(KindVersionMismatchError);
39
+ });
40
+
41
+ test("different org at the same version -> name mismatch (org IS compared)", () => {
42
+ expect(() =>
43
+ checkKindVersionMatch(
44
+ formatKindRef({ name: "@platforma-open/milaboratories.demo.kind", version: "1.2.3" }),
45
+ formatKindRef({ name: "@platforma-open/acme.demo.kind", version: "1.2.3" }),
46
+ ),
47
+ ).toThrow(KindVersionMismatchError);
48
+ });
49
+
50
+ test("an incidental npm-scope difference is normalized away", () => {
51
+ // Scoped vs. bare npm name: `npmNameToKindPath` drops the scope, so both
52
+ // resolve to the same `{org, name}` and the same kind matches.
53
+ expect(() =>
54
+ checkKindVersionMatch(
55
+ formatKindRef({ name: "@platforma-open/milaboratories.demo.kind", version: "1.2.3" }),
56
+ formatKindRef({ name: "milaboratories.demo.kind", version: "1.2.3" }),
57
+ ),
58
+ ).not.toThrow();
59
+ });
60
+ });
@@ -0,0 +1,55 @@
1
+ import { parseKindRef, type BlockKindReference } from "@milaboratories/pl-model-common";
2
+ import { npmNameToKindPath } from "../registry/schema_kinds";
3
+
4
+ /**
5
+ * Thrown when the kind version the model was compiled against does not match
6
+ * the kind version the facade ships. Typed so the publish command (and future
7
+ * callers) can distinguish this hard-fail from generic errors.
8
+ */
9
+ export class KindVersionMismatchError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = "KindVersionMismatchError";
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Pure pre-publish gate: assert the model's compiled-against kind and the
18
+ * facade's declared kind are the same kind at the same version. Throws
19
+ * {@link KindVersionMismatchError} on mismatch; success is "did not throw".
20
+ *
21
+ * No I/O — both inputs are already-resolved `{name}@{version}` references, so a
22
+ * mismatch aborts before any S3 write (a strict superset of "abort before the
23
+ * facade publish"). Reuses the SINGLE `{name}@{version}` codec `parseKindRef`
24
+ * from `block_kind_ref.ts` (§3) — it is NOT redefined here.
25
+ *
26
+ * @param modelKindRef reference the model was compiled against (`description.kind`)
27
+ * @param facadeKindDep concrete reference the facade ships (from resolve-refs)
28
+ */
29
+ export function checkKindVersionMatch(
30
+ modelKindRef: BlockKindReference,
31
+ facadeKindDep: BlockKindReference,
32
+ ): void {
33
+ const m = parseKindRef(modelKindRef);
34
+ const f = parseKindRef(facadeKindDep);
35
+
36
+ // Version is the load-bearing comparison. Exact-match, no semver range: the
37
+ // facade reference is already normalized to a concrete version by resolve-refs
38
+ // (which resolves `workspace:*`/`catalog:` via the kind's built manifest), so
39
+ // a range never reaches here.
40
+ if (m.version !== f.version) {
41
+ throw new KindVersionMismatchError(
42
+ `Kind version mismatch: model compiled against ${modelKindRef}, ` +
43
+ `facade declares ${facadeKindDep}. Rebuild the model against the declared kind.`,
44
+ );
45
+ }
46
+
47
+ const mLoc = npmNameToKindPath(m.name);
48
+ const fLoc = npmNameToKindPath(f.name);
49
+ if (mLoc.org !== fLoc.org || mLoc.name !== fLoc.name) {
50
+ throw new KindVersionMismatchError(
51
+ `Kind name mismatch: model compiled against ${modelKindRef}, ` +
52
+ `facade declares ${facadeKindDep}.`,
53
+ );
54
+ }
55
+ }
@@ -22,7 +22,14 @@ export type BlockPackDescriptionAbsolute = BlockPackDescription<
22
22
  * Resolves a raw `package.json`-form block-pack description against the
23
23
  * module root: workflow/model/ui paths via node module resolution, text and
24
24
  * binary fields in `meta` via `mapLocalToAbsolute`. Reads the resolved model
25
- * file to extract feature flags from its `BlockConfigContainer`.
25
+ * file to extract feature flags and the kind reference from its
26
+ * `BlockConfigContainer`.
27
+ *
28
+ * Both of those come from the built model rather than from `package.json`,
29
+ * because both are decided when the model is compiled. Carrying the kind here
30
+ * means a description means the same thing whichever side it was loaded from —
31
+ * a manifest already records it, and a caller should not have to know that a
32
+ * source package does not and re-read the model to find out.
26
33
  */
27
34
  export async function resolveBlockPackDescription(
28
35
  raw: BlockPackDescriptionRaw,
@@ -30,14 +37,18 @@ export async function resolveBlockPackDescription(
30
37
  ): Promise<BlockPackDescriptionAbsolute> {
31
38
  const components = resolveBlockComponents(raw.components, root);
32
39
  const meta = await resolveBlockPackMeta(raw.meta, root);
33
- const cfg = extractConfigGeneric(
34
- JSON.parse(await fsp.readFile(components.model.file, "utf-8")) as BlockConfigContainer,
35
- );
40
+ const container = JSON.parse(
41
+ await fsp.readFile(components.model.file, "utf-8"),
42
+ ) as BlockConfigContainer;
43
+ const cfg = extractConfigGeneric(container);
36
44
  return {
37
45
  ...raw,
38
46
  components,
39
47
  meta,
40
48
  featureFlags: cfg.featureFlags,
49
+ // The kind sits at the container level, above the render envelope, so it
50
+ // survives `extractConfigGeneric` normalizing that envelope away.
51
+ ...(container.kind !== undefined ? { kind: container.kind } : {}),
41
52
  };
42
53
  }
43
54
 
@@ -0,0 +1,60 @@
1
+ import type { BlockPackManifest } from "@milaboratories/pl-model-middle-layer";
2
+ import type { BlockRegistryV2 } from "./registry/registry";
3
+ import type { RelativeContentReader } from "./model";
4
+ import {
5
+ readModelCompiledKindRef,
6
+ readFacadeKindDependency,
7
+ resolveFacadeKind,
8
+ } from "./kind/resolve-refs";
9
+ import { checkKindVersionMatch } from "./kind/version-match";
10
+
11
+ /**
12
+ * Publish a block package, kind-first.
13
+ *
14
+ * This is the single forward-compat seam the deferred kind/template phases
15
+ * extend. It sequences exactly three steps around the existing facade publish:
16
+ *
17
+ * 1. resolve both kind refs — every assumption about how the facade names and locates
18
+ * its kind lives in `resolve-refs`, so this step has no shape details of its own,
19
+ * 2. run the pure version-match gate — hard-fails before ANY S3 write,
20
+ * 3. `publishKind` (kind content → `kinds/` tree, idempotent, source-hash
21
+ * guarded), then
22
+ * 4. `publishPackage` — the facade block, byte-for-byte unchanged.
23
+ *
24
+ * A block that declares no kind (`description.kind` absent) publishes exactly as
25
+ * before: the gate and `publishKind` are skipped entirely.
26
+ *
27
+ * The channel marker / coords write / refresh tail stays in the publish command
28
+ * and runs only after this resolves.
29
+ */
30
+ export async function publishBlock(
31
+ registry: BlockRegistryV2,
32
+ manifest: BlockPackManifest,
33
+ facadeDir: string,
34
+ fileReader: RelativeContentReader,
35
+ ): Promise<void> {
36
+ const modelKindRef = readModelCompiledKindRef(manifest);
37
+
38
+ if (modelKindRef !== undefined) {
39
+ // 1. resolve the facade-declared kind dependency
40
+ const facadeDep = readFacadeKindDependency(facadeDir);
41
+ if (facadeDep === undefined) {
42
+ throw new Error(
43
+ `Model was compiled against kind ${modelKindRef}, but the facade declares ` +
44
+ `no kind dependency in its package.json.`,
45
+ );
46
+ }
47
+
48
+ // Resolve to concrete artifacts (concrete version + kind content + reader).
49
+ const resolvedKind = await resolveFacadeKind(facadeDir, facadeDep.npmName);
50
+
51
+ // 2. pure gate — throws KindVersionMismatchError before any S3 write
52
+ checkKindVersionMatch(modelKindRef, resolvedKind.ref);
53
+
54
+ // 3. kind FIRST — idempotent, source-hash-guarded write to the kinds/ tree
55
+ await registry.publishKind(resolvedKind.manifest, resolvedKind.fileReader);
56
+ }
57
+
58
+ // 4. facade — unchanged behavior
59
+ await registry.publishPackage(manifest, fileReader);
60
+ }
@@ -1,3 +1,5 @@
1
1
  export * from "./registry";
2
2
  export * from "./registry_reader";
3
3
  export * from "./schema_public";
4
+ export * from "./schema_kinds";
5
+ export * from "./kind_resolver";
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import * as semver from "semver";
3
+ import { AnyChannel, StableChannel } from "@milaboratories/pl-model-middle-layer";
4
+ import type { BlockPackId } from "@milaboratories/pl-model-middle-layer";
5
+ import { parseSelector, resolveKind, selectorToRange } from "./kind_resolver";
6
+ import type { KindOverview } from "./schema_kinds";
7
+
8
+ const block = (version: string): BlockPackId => ({
9
+ organization: "acme",
10
+ name: "blk",
11
+ version,
12
+ });
13
+
14
+ // Implementing block ids, one per kind version. Block versions are independent
15
+ // of the kind versions they implement.
16
+ const b100 = block("3.0.0"); // implements kind 1.0.0 (stable)
17
+ const b120 = block("3.1.0"); // implements kind 1.2.0 (stable)
18
+ const b130 = block("3.2.0"); // implements kind 1.3.0 (stable)
19
+ const b200 = block("4.0.0"); // implements kind 2.0.0 (unstable only)
20
+
21
+ /**
22
+ * Overview with a multi-step version ladder inside one major, so patch-tier and
23
+ * minor-tier resolution land on different versions, and a top kind version
24
+ * (2.0.0) whose only implementer sits off the stable channel — the
25
+ * `no-stable-implementation` fixture.
26
+ */
27
+ const overview: KindOverview = {
28
+ schema: "v1",
29
+ implementers: [
30
+ { id: b100, kindVersion: "1.0.0", channels: [StableChannel] },
31
+ { id: b120, kindVersion: "1.2.0", channels: [StableChannel] },
32
+ { id: b130, kindVersion: "1.3.0", channels: [StableChannel] },
33
+ { id: b200, kindVersion: "2.0.0", channels: ["unstable"] },
34
+ ],
35
+ kindVersions: [
36
+ { kindVersion: "1.0.0", latestByChannel: { [StableChannel]: b100, [AnyChannel]: b100 } },
37
+ { kindVersion: "1.2.0", latestByChannel: { [StableChannel]: b120, [AnyChannel]: b120 } },
38
+ { kindVersion: "1.3.0", latestByChannel: { [StableChannel]: b130, [AnyChannel]: b130 } },
39
+ { kindVersion: "2.0.0", latestByChannel: { [AnyChannel]: b200 } },
40
+ ],
41
+ };
42
+
43
+ describe("selector parsing / range mapping", () => {
44
+ test("leading operator selects the tier; bare version is exact", () => {
45
+ expect(parseSelector("1.2.0")).toEqual({ op: "exact", version: "1.2.0" });
46
+ expect(parseSelector("~1.2.0")).toEqual({ op: "patch", version: "1.2.0" });
47
+ expect(parseSelector("^1.2.0")).toEqual({ op: "minor", version: "1.2.0" });
48
+ });
49
+
50
+ test("selectorToRange emits the explicit range for each tier", () => {
51
+ expect(selectorToRange({ op: "exact", version: "1.2.0" })).toBe("=1.2.0");
52
+ expect(selectorToRange({ op: "patch", version: "1.2.0" })).toBe(">=1.2.0 <1.3.0-0");
53
+ expect(selectorToRange({ op: "minor", version: "1.0.0" })).toBe(">=1.0.0 <2.0.0-0");
54
+ });
55
+
56
+ test("at or above 1.0.0 the explicit ranges are equivalent to npm ~ and ^", () => {
57
+ for (const version of ["1.0.0", "1.2.3", "2.0.0", "10.4.0", "1.2.0-rc.1"]) {
58
+ expect(semver.validRange(selectorToRange({ op: "patch", version }))).toBe(
59
+ semver.validRange(`~${version}`),
60
+ );
61
+ expect(semver.validRange(selectorToRange({ op: "minor", version }))).toBe(
62
+ semver.validRange(`^${version}`),
63
+ );
64
+ }
65
+ });
66
+
67
+ // npm collapses `^0.2.0` and `~0.2.0` to the same range, treating a 0.x minor
68
+ // as the breaking boundary. The kind scheme puts params-breaks on the major at
69
+ // every major, so the two tiers must stay distinct for a 0.x kind.
70
+ test("below 1.0.0 the tiers stay distinct, unlike npm ~ and ^", () => {
71
+ expect(selectorToRange({ op: "patch", version: "0.2.0" })).toBe(">=0.2.0 <0.3.0-0");
72
+ expect(selectorToRange({ op: "minor", version: "0.2.0" })).toBe(">=0.2.0 <1.0.0-0");
73
+
74
+ expect(semver.validRange("^0.2.0")).toBe(semver.validRange("~0.2.0"));
75
+ expect(semver.satisfies("0.3.0", selectorToRange({ op: "minor", version: "0.2.0" }))).toBe(
76
+ true,
77
+ );
78
+ expect(semver.satisfies("0.3.0", selectorToRange({ op: "patch", version: "0.2.0" }))).toBe(
79
+ false,
80
+ );
81
+ expect(semver.satisfies("1.0.0", selectorToRange({ op: "minor", version: "0.2.0" }))).toBe(
82
+ false,
83
+ );
84
+ });
85
+
86
+ test("an unparseable version is rejected at range construction", () => {
87
+ expect(() => selectorToRange({ op: "minor", version: "latest" })).toThrow();
88
+ });
89
+ });
90
+
91
+ describe("resolveKind", () => {
92
+ test("exact @X.Y.Z hit -> that kind version's stable block", () => {
93
+ const r = resolveKind(overview, "1.2.0", { allowUnstable: false });
94
+ expect(r).toEqual({ ok: true, blockId: b120, channel: StableChannel });
95
+ });
96
+
97
+ test("minor float ^1.0.0 picks the newest matching kind version (1.3.0)", () => {
98
+ const r = resolveKind(overview, "^1.0.0", { allowUnstable: false });
99
+ // ^1.0.0 == >=1.0.0 <2.0.0 -> maxSatisfying is 1.3.0.
100
+ expect(r).toEqual({ ok: true, blockId: b130, channel: StableChannel });
101
+ });
102
+
103
+ test("patch float ~1.2.0 stays within the minor line", () => {
104
+ const r = resolveKind(overview, "~1.2.0", { allowUnstable: false });
105
+ // ~1.2.0 == >=1.2.0 <1.3.0 -> maxSatisfying is 1.2.0.
106
+ expect(r).toEqual({ ok: true, blockId: b120, channel: StableChannel });
107
+ });
108
+
109
+ test("kind version exists but only an unstable implementer -> no-stable-implementation", () => {
110
+ const r = resolveKind(overview, "2.0.0", { allowUnstable: false });
111
+ expect(r).toEqual({ ok: false, reason: "no-stable-implementation" });
112
+ });
113
+
114
+ test("allowUnstable lifts the same 2.0.0 to the any channel", () => {
115
+ const r = resolveKind(overview, "2.0.0", { allowUnstable: true });
116
+ expect(r).toEqual({ ok: true, blockId: b200, channel: AnyChannel });
117
+ });
118
+
119
+ test("selector satisfying zero kind versions -> no-matching-kind-version", () => {
120
+ const r = resolveKind(overview, "5.0.0", { allowUnstable: false });
121
+ expect(r).toEqual({ ok: false, reason: "no-matching-kind-version" });
122
+ });
123
+ });