@platforma-sdk/block-tools 2.14.0 → 2.14.2

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 (30) hide show
  1. package/dist/cli.js +17 -13
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +780 -730
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/structure/engine/testing.d.ts +3 -0
  6. package/dist/structure/rules/root-changeset-config.d.ts +8 -0
  7. package/dist/structure/rules/shared/initial-version.d.ts +10 -0
  8. package/package.json +4 -4
  9. package/src/structure/__tests__/facade-end-state.test.ts +68 -3
  10. package/src/structure/__tests__/init-catalog-completeness.test.ts +86 -0
  11. package/src/structure/__tests__/init-lint-clean.test.ts +84 -0
  12. package/src/structure/__tests__/model-package-json.snapshot.test.ts +1 -0
  13. package/src/structure/__tests__/root-changeset-config.test.ts +94 -0
  14. package/src/structure/__tests__/root-ci-workflows.test.ts +3 -0
  15. package/src/structure/engine/testing.ts +4 -0
  16. package/src/structure/rules/block-package-json.ts +2 -1
  17. package/src/structure/rules/kind-package-json.ts +5 -1
  18. package/src/structure/rules/model-package-json.ts +5 -1
  19. package/src/structure/rules/root-changeset-config.ts +61 -0
  20. package/src/structure/rules/root-package-json.ts +2 -1
  21. package/src/structure/rules/root-pnpm-workspace.ts +3 -0
  22. package/src/structure/rules/root.ts +15 -1
  23. package/src/structure/rules/shared/initial-version.ts +17 -0
  24. package/src/structure/rules/software-package-json.ts +6 -0
  25. package/src/structure/rules/test-package-json.ts +5 -1
  26. package/src/structure/rules/ui-package-json.ts +5 -1
  27. package/src/structure/rules/workflow-package-json.ts +5 -1
  28. package/src/structure/templates/text/model/src/index.tpl.ts +1 -1
  29. package/src/structure/templates/text/workflows/build.tpl.yaml +1 -1
  30. package/src/structure/templates/text/workflows/mark-stable.tpl.yaml +1 -1
@@ -19,6 +19,9 @@ export type SimulateInitInput = {
19
19
  * Absent → the SDK catalog keeps its seed versions (no bump), matching
20
20
  * an offline scaffold; tests that assert init writes latest pass a mock. */
21
21
  registryLookup?: (packageName: string) => string | undefined;
22
+ /** Sync lookup for the derived catalog pins. Absent → those entries are
23
+ * left out of the catalog; the real `init` always passes one. */
24
+ derivedPinLookup?: (catalogEntry: string) => string | undefined;
22
25
  };
23
26
  export type SimulatedInit = {
24
27
  fs: MemoryFileSystem;
@@ -0,0 +1,8 @@
1
+ /** Seeded so the scaffold's own PR passes the `require-package-path-bump` gate
2
+ * without bumping: `changeset version` consumes it and leaves the facade at
3
+ * `INITIAL_MODULE_VERSION`, so the first version a user sees is 1.0.0 rather
4
+ * than 1.0.1. Seeded (not managed) — a refresh must never resurrect it once
5
+ * the first release has consumed it. */
6
+ export declare const EMPTY_CHANGESET_SEED = "---\n---\n\nInitial release.\n";
7
+ export declare function rootChangesetConfigInitial(): Record<string, unknown>;
8
+ export declare function rootChangesetConfigRules(): void;
@@ -0,0 +1,10 @@
1
+ /** Seeded at init for every module. `pnpm pack` rewrites the facade's
2
+ * `workspace:*` devDeps to concrete ranges, so a versionless sibling breaks
3
+ * `do-pack`. Changesets owns the field afterwards. */
4
+ export declare const INITIAL_MODULE_VERSION = "1.0.0";
5
+ /** Backfill `version` on a sibling that has none — a block scaffolded before
6
+ * the initials seeded the field keeps its versionless manifests through a
7
+ * `structure refresh`, and `do-pack` then dies resolving the facade's
8
+ * `workspace:*` deps. Never touches an existing value: past init the field
9
+ * belongs to changesets. */
10
+ export declare function ensureModuleVersion(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/block-tools",
3
- "version": "2.14.0",
3
+ "version": "2.14.2",
4
4
  "description": "Utility to manipulate Platforma Blocks and Block Registry",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -34,13 +34,13 @@
34
34
  "undici": "~7.16.0",
35
35
  "yaml": "^2.8.0",
36
36
  "zod": "~3.25.76",
37
+ "@milaboratories/pl-model-backend": "1.4.21",
37
38
  "@milaboratories/pl-http": "1.2.4",
39
+ "@milaboratories/ts-helpers": "1.8.6",
38
40
  "@milaboratories/pl-model-common": "1.48.0",
39
41
  "@milaboratories/pl-model-middle-layer": "1.32.0",
40
- "@platforma-sdk/blocks-deps-updater": "2.2.0",
41
- "@milaboratories/ts-helpers": "1.8.6",
42
- "@milaboratories/pl-model-backend": "1.4.21",
43
42
  "@milaboratories/resolve-helper": "1.1.3",
43
+ "@platforma-sdk/blocks-deps-updater": "2.2.0",
44
44
  "@platforma-sdk/package-builder-lib": "1.3.0"
45
45
  },
46
46
  "devDependencies": {
@@ -9,6 +9,7 @@ import { classifyOne, type WorkspacePackage } from "../engine/discovery";
9
9
  import { STRUCTURE } from "../structure-definition";
10
10
  import { run as engineRun } from "../engine/runner";
11
11
  import { defaultTemplateProvider, simulateInit } from "../engine/testing";
12
+ import { INITIAL_MODULE_VERSION } from "../rules/shared/initial-version";
12
13
 
13
14
  const TEMPLATES = defaultTemplateProvider();
14
15
 
@@ -166,14 +167,78 @@ describe("facade: `prepublishOnly` publish target branches on isSdkInternal", ()
166
167
  });
167
168
 
168
169
  describe("facade: sibling package privacy", () => {
169
- test("model / ui / workflow / test are `private` with no `version`", () => {
170
+ // Private but versioned. `pnpm pack` in the facade has to rewrite each
171
+ // `workspace:*` devDep to a concrete range, so a versionless sibling fails
172
+ // `do-pack` with ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL. The kind needs
173
+ // one for a second reason: its own src imports `version` from its
174
+ // package.json.
175
+ test("kind / model / ui / workflow / test are `private` and versioned", () => {
170
176
  const { fs } = simulateInit({ vars: VARS });
171
- for (const scope of ["model", "ui", "workflow", "test"]) {
177
+ for (const scope of ["kind", "model", "ui", "workflow", "test"]) {
172
178
  const p = JSON.parse(fs.read(`${scope}/package.json`));
173
179
  expect(p.private, `${scope} must be private`).toBe(true);
174
- expect("version" in p, `${scope} must not carry a version`).toBe(false);
180
+ expect(p.version, `${scope} must carry a version`).toBe(INITIAL_MODULE_VERSION);
175
181
  }
176
182
  });
183
+
184
+ test("every `workspace:*` dep of the facade resolves to a versioned package", () => {
185
+ const { fs } = simulateInit({ vars: VARS });
186
+ const facade = JSON.parse(fs.read("block/package.json"));
187
+ const byName = new Map<string, string | undefined>();
188
+ for (const scope of ["kind", "model", "ui", "workflow", "test"]) {
189
+ const p = JSON.parse(fs.read(`${scope}/package.json`));
190
+ byName.set(p.name, p.version);
191
+ }
192
+
193
+ const workspaceDeps = Object.entries({
194
+ ...facade.dependencies,
195
+ ...facade.devDependencies,
196
+ }).filter(([, spec]) => String(spec).startsWith("workspace:"));
197
+ expect(workspaceDeps.length).toBeGreaterThan(0);
198
+
199
+ for (const [name] of workspaceDeps) {
200
+ expect(byName.get(name), `${name} is a workspace dep with no version`).toBeDefined();
201
+ }
202
+ });
203
+ });
204
+
205
+ describe("facade: sibling versions on refresh", () => {
206
+ // A block scaffolded before the initials seeded `version` carries versionless
207
+ // sibling manifests. `refresh` never re-runs an initial for a file that
208
+ // exists, so the bodies have to backfill the field themselves — otherwise
209
+ // `do-pack` dies on ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL rewriting the
210
+ // facade's `workspace:*` deps.
211
+ test("a refresh backfills `version` on versionless siblings", () => {
212
+ const { fs, ctx } = simulateInit({ vars: VARS });
213
+ const scopes = ["kind", "model", "ui", "workflow", "test"];
214
+ for (const scope of scopes) {
215
+ const p = JSON.parse(fs.read(`${scope}/package.json`));
216
+ delete p.version;
217
+ fs.write(`${scope}/package.json`, `${JSON.stringify(p, null, 2)}\n`);
218
+ }
219
+
220
+ const refreshCtx: RunContext = { ...ctx, dryRun: false };
221
+ expect(() => engineRun(STRUCTURE, fs, refreshCtx, { templates: TEMPLATES })).not.toThrow();
222
+
223
+ for (const scope of scopes) {
224
+ const p = JSON.parse(fs.read(`${scope}/package.json`));
225
+ expect(p.version, `${scope} must be versioned after refresh`).toBe(INITIAL_MODULE_VERSION);
226
+ // `version` sits right after `name` — the canonical order the formatter
227
+ // enforces; a backfill appended at the end would fail `oxfmt --check`.
228
+ expect(Object.keys(p).indexOf("version")).toBe(1);
229
+ }
230
+ });
231
+
232
+ test("a refresh leaves an existing sibling `version` alone (changesets owns it)", () => {
233
+ const { fs, ctx } = simulateInit({ vars: VARS });
234
+ const bumped = JSON.parse(fs.read("model/package.json"));
235
+ bumped.version = "3.4.5";
236
+ fs.write("model/package.json", `${JSON.stringify(bumped, null, 2)}\n`);
237
+
238
+ engineRun(STRUCTURE, fs, { ...ctx, dryRun: false }, { templates: TEMPLATES });
239
+
240
+ expect(JSON.parse(fs.read("model/package.json")).version).toBe("3.4.5");
241
+ });
177
242
  });
178
243
 
179
244
  describe("facade: legacy shim cleanup on refresh", () => {
@@ -0,0 +1,86 @@
1
+ // One `"x": "catalog:"` with no `x` catalog key fails the whole `pnpm install`
2
+ // with ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC. The dep sets live in
3
+ // `rules/*-package-json.ts` and the catalog in `rules/root-pnpm-workspace.ts`;
4
+ // nothing else ties the two together.
5
+
6
+ import { describe, test, expect } from "vitest";
7
+ import { parse as parseYaml } from "yaml";
8
+ import { simulateInit } from "../engine/testing";
9
+ import { MemoryFileSystem } from "../engine/fs/memory";
10
+ import { SDK_CATALOG_PACKAGES, DERIVED_CATALOG_PINS } from "../rules/root-pnpm-workspace";
11
+ import type { BlockVars } from "../engine/api";
12
+
13
+ const DEP_SECTIONS = [
14
+ "dependencies",
15
+ "devDependencies",
16
+ "peerDependencies",
17
+ "optionalDependencies",
18
+ ] as const;
19
+
20
+ const BASE_VARS: BlockVars = {
21
+ facadeName: "@platforma-open/test-org.demo",
22
+ baseName: "test-org.demo",
23
+ npmOrg: "@platforma-open",
24
+ orgScope: "test-org",
25
+ shortName: "demo",
26
+ };
27
+
28
+ // Distinct mock versions, so a wrong-name lookup cannot pass by accident.
29
+ const registryLookup = (name: string): string | undefined =>
30
+ SDK_CATALOG_PACKAGES.includes(name) ? "9.9.9" : undefined;
31
+ const derivedPinLookup = (entry: string): string | undefined =>
32
+ DERIVED_CATALOG_PINS.some((pin) => pin.entry === entry) ? "8.8.8" : undefined;
33
+
34
+ /** `path → catalog-referencing dep names`, over every package.json written. */
35
+ function catalogReferences(fs: MemoryFileSystem): Map<string, string[]> {
36
+ const out = new Map<string, string[]>();
37
+ for (const path of fs.list("")) {
38
+ if (!path.endsWith("package.json")) continue;
39
+ const manifest = JSON.parse(fs.read(path)) as Record<string, unknown>;
40
+ const names: string[] = [];
41
+ for (const section of DEP_SECTIONS) {
42
+ const deps = manifest[section];
43
+ if (typeof deps !== "object" || deps === null) continue;
44
+ for (const [name, spec] of Object.entries(deps as Record<string, string>)) {
45
+ if (spec === "catalog:") names.push(name);
46
+ }
47
+ }
48
+ if (names.length > 0) out.set(path, names);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function catalogOf(fs: MemoryFileSystem): Record<string, string> {
54
+ const parsed = parseYaml(fs.read("pnpm-workspace.yaml")) as {
55
+ catalog?: Record<string, string>;
56
+ };
57
+ return parsed.catalog ?? {};
58
+ }
59
+
60
+ describe("init output is installable: every `catalog:` dep has a catalog entry", () => {
61
+ for (const [shape, vars] of [
62
+ ["without software", BASE_VARS],
63
+ ["with software", { ...BASE_VARS, softwarePlatform: "python" }],
64
+ ] as const) {
65
+ test(`a block ${shape}`, () => {
66
+ const { fs } = simulateInit({ vars, registryLookup, derivedPinLookup });
67
+ const catalog = catalogOf(fs);
68
+ const references = catalogReferences(fs);
69
+
70
+ // Guard the guard: a walk that finds nothing would pass vacuously.
71
+ expect(references.size).toBeGreaterThan(0);
72
+
73
+ for (const [path, names] of references) {
74
+ for (const name of names) {
75
+ expect(
76
+ catalog[name],
77
+ `${path} declares "${name}": "catalog:", but pnpm-workspace.yaml has no ` +
78
+ `'${name}' catalog entry — pnpm install fails with ` +
79
+ `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC. Add it to ` +
80
+ `SDK_CATALOG_PACKAGES or INFRA_CATALOG_FLOOR in rules/root-pnpm-workspace.ts.`,
81
+ ).toBeDefined();
82
+ }
83
+ }
84
+ });
85
+ }
86
+ });
@@ -0,0 +1,84 @@
1
+ // `ts-builder format` runs oxlint with `--deny-warnings`, so one unused
2
+ // parameter in a seeded file fails `pnpm fmt` — the last step of `upgrade-sdk`.
3
+
4
+ import { describe, test, expect } from "vitest";
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { simulateInit } from "../engine/testing";
11
+ import type { BlockVars } from "../engine/api";
12
+
13
+ const VARS: BlockVars = {
14
+ facadeName: "@platforma-open/test-org.demo",
15
+ baseName: "test-org.demo",
16
+ npmOrg: "@platforma-open",
17
+ orgScope: "test-org",
18
+ shortName: "demo",
19
+ };
20
+
21
+ function resolveTsBuilderConfig(configFile: string): string {
22
+ return fileURLToPath(import.meta.resolve(`@milaboratories/ts-builder/configs/${configFile}`));
23
+ }
24
+
25
+ /** ts-builder pins its own oxlint, so block-tools' PATH copy is a different
26
+ * version. */
27
+ function resolveOxlintBinary(): string | undefined {
28
+ const configsDir = path.dirname(resolveTsBuilderConfig("oxlint-base.json"));
29
+ const binary = path.resolve(configsDir, "..", "..", "node_modules", ".bin", "oxlint");
30
+ return existsSync(binary) ? binary : undefined;
31
+ }
32
+
33
+ /** The scopes whose `fmt` script lints. */
34
+ const LINTED_SCOPES = ["kind", "model", "test", "ui"];
35
+
36
+ describe("init output is lint-clean (oxlint --deny-warnings)", () => {
37
+ const oxlint = resolveOxlintBinary();
38
+
39
+ test.runIf(oxlint)("every seeded source passes its scope's oxlint config", () => {
40
+ const { fs } = simulateInit({ vars: VARS });
41
+ const root = mkdtempSync(path.join(tmpdir(), "init-lint-clean-"));
42
+
43
+ for (const scope of LINTED_SCOPES) {
44
+ const configPath = `${scope}/.oxlintrc.json`;
45
+ expect(fs.exists(configPath), `${scope} must carry an .oxlintrc.json`).toBe(true);
46
+
47
+ // The generated `extends` are relative to an installed node_modules.
48
+ const config = JSON.parse(fs.read(configPath)) as { extends?: string[] };
49
+ const extendsAbsolute = (config.extends ?? []).map((entry) =>
50
+ resolveTsBuilderConfig(path.basename(entry)),
51
+ );
52
+ expect(
53
+ extendsAbsolute.length,
54
+ `${scope}/.oxlintrc.json must extend a config`,
55
+ ).toBeGreaterThan(0);
56
+
57
+ mkdirSync(path.join(root, scope), { recursive: true });
58
+ writeFileSync(
59
+ path.join(root, scope, ".oxlintrc.json"),
60
+ JSON.stringify({ ...config, extends: extendsAbsolute }),
61
+ );
62
+ for (const file of fs.list(`${scope}/src`)) {
63
+ const target = path.join(root, file);
64
+ mkdirSync(path.dirname(target), { recursive: true });
65
+ writeFileSync(target, fs.read(file));
66
+ }
67
+
68
+ const result = spawnSync(
69
+ oxlint!,
70
+ ["--config", path.join(root, scope, ".oxlintrc.json"), "--deny-warnings", "src"],
71
+ { cwd: path.join(root, scope), encoding: "utf-8" },
72
+ );
73
+ expect(
74
+ result.status,
75
+ `seeded ${scope}/src is not lint-clean, so \`pnpm run fmt\` (and with it ` +
76
+ `\`pnpm run upgrade-sdk\`) fails on a fresh block:\n${result.stdout}\n${result.stderr}`,
77
+ ).toBe(0);
78
+ }
79
+ });
80
+
81
+ if (!oxlint) {
82
+ test.skip("oxlint binary unavailable — skipping lint-clean guard", () => {});
83
+ }
84
+ });
@@ -9,6 +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
13
  "private": true,
13
14
  "type": "module",
14
15
  "main": "dist/index.cjs",
@@ -0,0 +1,94 @@
1
+ // CI runs `pnpm run version-packages` on every PR, so a block with no
2
+ // `.changeset/config.json` fails its first CI run.
3
+
4
+ import { describe, test, expect } from "vitest";
5
+ import { simulateInit, defaultTemplateProvider } from "../engine/testing";
6
+ import { run as engineRun } from "../engine/runner";
7
+ import { discoverRunContext } from "../engine/discovery-fs";
8
+ import { STRUCTURE } from "../structure-definition";
9
+ import { MemoryFileSystem } from "../engine/fs/memory";
10
+ import type { BlockVars } from "../engine/api";
11
+
12
+ const VARS: BlockVars = {
13
+ facadeName: "@platforma-open/test-org.demo",
14
+ baseName: "test-org.demo",
15
+ npmOrg: "@platforma-open",
16
+ orgScope: "test-org",
17
+ shortName: "demo",
18
+ };
19
+
20
+ const PATH = ".changeset/config.json";
21
+
22
+ function refresh(fs: MemoryFileSystem, isSdkInternal = false) {
23
+ return engineRun(STRUCTURE, fs, discoverRunContext({ fs, isSdkInternal }), {
24
+ templates: defaultTemplateProvider(),
25
+ rediscover: () => discoverRunContext({ fs, isSdkInternal, dryRun: true }),
26
+ });
27
+ }
28
+
29
+ describe("root .changeset/config.json", () => {
30
+ test("init writes the config CI needs", () => {
31
+ const { fs } = simulateInit({ vars: VARS });
32
+ const config = JSON.parse(fs.read(PATH));
33
+ expect(config.changelog).toBe("@changesets/cli/changelog");
34
+ expect(config.commit).toBe(false);
35
+ expect(config.access).toBe("restricted");
36
+ expect(config.baseBranch).toBe("main");
37
+ expect(config.updateInternalDependencies).toBe("patch");
38
+ });
39
+
40
+ test("refresh creates it for a block that predates the rule", () => {
41
+ const { fs } = simulateInit({ vars: VARS });
42
+ fs.delete(".changeset");
43
+ expect(fs.exists(PATH)).toBe(false);
44
+
45
+ refresh(fs);
46
+ expect(JSON.parse(fs.read(PATH)).baseBranch).toBe("main");
47
+ });
48
+
49
+ test("refresh keeps author-owned lists and re-asserts the CI fields", () => {
50
+ const { fs } = simulateInit({ vars: VARS });
51
+ const authored = {
52
+ ...JSON.parse(fs.read(PATH)),
53
+ $schema: "https://unpkg.com/@changesets/config@3.0.2/schema.json",
54
+ ignore: ["@platforma-open/test-org.demo.ui"],
55
+ access: "public",
56
+ "//privatePackages": "a note the author added",
57
+ };
58
+ fs.write(PATH, JSON.stringify(authored, null, 2));
59
+
60
+ refresh(fs);
61
+ const after = JSON.parse(fs.read(PATH));
62
+ expect(after.ignore).toEqual(["@platforma-open/test-org.demo.ui"]);
63
+ expect(after.$schema).toBe("https://unpkg.com/@changesets/config@3.0.2/schema.json");
64
+ expect(after["//privatePackages"]).toBe("a note the author added");
65
+ expect(after.access).toBe("restricted");
66
+ });
67
+
68
+ test("sdk-internal blocks get no config — the monorepo owns it", () => {
69
+ const { fs } = simulateInit({ vars: VARS, isSdkInternal: true });
70
+ expect(fs.exists(PATH)).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("root .changeset/scaffold.md", () => {
75
+ const SEED = ".changeset/scaffold.md";
76
+
77
+ // An empty changeset names no package, so `changeset version` consumes it
78
+ // without bumping. That is what keeps the first RELEASED facade version at
79
+ // 1.0.0 instead of 1.0.1, and it passes CI's require-package-path-bump gate.
80
+ test("init seeds an empty changeset — no package named", () => {
81
+ const { fs } = simulateInit({ vars: VARS });
82
+ const body = fs.read(SEED);
83
+ expect(body.startsWith("---\n---\n")).toBe(true);
84
+ expect(body).not.toContain(VARS.facadeName);
85
+ });
86
+
87
+ test("refresh does not resurrect it once a release consumed it", () => {
88
+ const { fs } = simulateInit({ vars: VARS });
89
+ fs.delete(SEED);
90
+
91
+ refresh(fs);
92
+ expect(fs.exists(SEED)).toBe(false);
93
+ });
94
+ });
@@ -37,6 +37,9 @@ describe("root CI workflows (fixed, generated)", () => {
37
37
  // Shared constants.
38
38
  expect(build).toContain("team-id: 'ciplopen'");
39
39
  expect(build).toContain("test: true");
40
+ // Both workflows track the toolchain's node major (mise pins 22).
41
+ expect(build).toContain("node-version: '22.x'");
42
+ expect(fs.read(MARK_STABLE)).toContain("node-version: '22.x'");
40
43
  // Opt-in gate: the published `block` package must be bumped on PRs.
41
44
  expect(build).toContain("require-package-path-bump: true");
42
45
  // No bare `build` script exists (root-package-json removes it), so both
@@ -45,6 +45,9 @@ export type SimulateInitInput = {
45
45
  * Absent → the SDK catalog keeps its seed versions (no bump), matching
46
46
  * an offline scaffold; tests that assert init writes latest pass a mock. */
47
47
  registryLookup?: (packageName: string) => string | undefined;
48
+ /** Sync lookup for the derived catalog pins. Absent → those entries are
49
+ * left out of the catalog; the real `init` always passes one. */
50
+ derivedPinLookup?: (catalogEntry: string) => string | undefined;
48
51
  };
49
52
 
50
53
  export type SimulatedInit = {
@@ -84,6 +87,7 @@ export function simulateInit(input: SimulateInitInput): SimulatedInit {
84
87
  templates,
85
88
  initMode: true,
86
89
  registryLookup: input.registryLookup,
90
+ derivedPinLookup: input.derivedPinLookup,
87
91
  });
88
92
 
89
93
  // Re-discover from the written FS — the returned ctx is what `check`
@@ -18,6 +18,7 @@ import {
18
18
  } from "../engine/api";
19
19
  import { findModules, scopeDepMaps } from "../engine/ctx";
20
20
  import { canonicalPackageJsonOrder } from "./shared/key-order";
21
+ import { INITIAL_MODULE_VERSION } from "./shared/initial-version";
21
22
 
22
23
  /** Per-org publish coordinates: the S3 upload bucket and the public registry
23
24
  * serve URL the facade's `prepublishOnly` script passes to `block-tools
@@ -177,7 +178,7 @@ export function blockPackageJsonInitial(ctx: RunContext): Record<string, unknown
177
178
  assertBlockFieldsFilled(block);
178
179
  return {
179
180
  name: `${v.facadeName}.block`,
180
- version: "1.0.0",
181
+ version: INITIAL_MODULE_VERSION,
181
182
  // Facade is ESM: rolldown-plugin-dts requires it, and the BlockPointer's
182
183
  // `import.meta.url` path math needs it.
183
184
  type: "module",
@@ -29,6 +29,7 @@ import {
29
29
  type RunContext,
30
30
  } from "../engine/api";
31
31
  import { canonicalPackageJsonOrder } from "./shared/key-order";
32
+ import { ensureModuleVersion, INITIAL_MODULE_VERSION } from "./shared/initial-version";
32
33
 
33
34
  const KIND_EXPORTS = {
34
35
  ".": {
@@ -44,6 +45,7 @@ export function kindPackageJsonInitial(ctx: RunContext): Record<string, unknown>
44
45
  const v = ctx.blockVars;
45
46
  return {
46
47
  name: `${v.facadeName}.kind`,
48
+ version: INITIAL_MODULE_VERSION,
47
49
  private: true,
48
50
  type: "module",
49
51
  // `main` is the CommonJS entry (require fallback), `module` the ESM one —
@@ -81,7 +83,9 @@ export function kindPackageJsonInitial(ctx: RunContext): Record<string, unknown>
81
83
  export function kindPackageJsonRules(): void {
82
84
  // Controlled sibling — workspace-only, never published to npm. `private: true`
83
85
  // makes npm refuse to publish it; the `version` is kept (changesets-owned) and
84
- // is what the kind's on-wire `{name}@{version}` reference is baked from.
86
+ // is what the kind's on-wire `{name}@{version}` reference is baked from — only
87
+ // backfilled when the manifest predates the seeded field.
88
+ ensureModuleVersion();
85
89
  ensureField("private", true);
86
90
 
87
91
  ensureField("type", "module");
@@ -28,6 +28,7 @@ import {
28
28
  } from "../engine/api";
29
29
  import { scopeDepMaps } from "../engine/ctx";
30
30
  import { canonicalPackageJsonOrder } from "./shared/key-order";
31
+ import { ensureModuleVersion, INITIAL_MODULE_VERSION } from "./shared/initial-version";
31
32
  import { COLOCATED_TEST_GLOB } from "./shared/colocated-tests";
32
33
  import { removeRetiredToolchainDeps } from "./shared/retired-deps";
33
34
 
@@ -35,6 +36,7 @@ export function modelPackageJsonInitial(ctx: RunContext): Record<string, unknown
35
36
  const v = ctx.blockVars;
36
37
  return {
37
38
  name: `${v.facadeName}.model`,
39
+ version: INITIAL_MODULE_VERSION,
38
40
  private: true,
39
41
  type: "module",
40
42
  // The block-model build emits both index.cjs and index.js. `main` is
@@ -77,7 +79,9 @@ export function modelPackageJsonInitial(ctx: RunContext): Record<string, unknown
77
79
  export function modelPackageJsonRules(): void {
78
80
  // Controlled sibling — workspace-only, never published. `private: true` makes
79
81
  // npm refuse to publish it; the `version` is kept (changesets-owned) so the
80
- // packed template's lib carries a version.
82
+ // packed template's lib carries a version, and backfilled when the manifest
83
+ // predates the seeded field.
84
+ ensureModuleVersion();
81
85
  ensureField("private", true);
82
86
 
83
87
  ensureField("type", "module");
@@ -0,0 +1,61 @@
1
+ // Root `.changeset/config.json`. CI runs `pnpm run version-packages`
2
+ // (`changeset version`) on every PR and sets `require-package-path-bump`, so a
3
+ // block without this file fails its first CI run with "There is no .changeset
4
+ // folder".
5
+ //
6
+ // Managed, not fixed: `$schema` is whatever `changeset init` last wrote (seven
7
+ // versions are in use across the blocks) and `fixed`/`linked`/`ignore` are
8
+ // author-owned lists. Only the fields CI depends on are re-asserted.
9
+
10
+ import { ensureField, enforceFieldOrder } from "../engine/api";
11
+
12
+ /** Seeded so the scaffold's own PR passes the `require-package-path-bump` gate
13
+ * without bumping: `changeset version` consumes it and leaves the facade at
14
+ * `INITIAL_MODULE_VERSION`, so the first version a user sees is 1.0.0 rather
15
+ * than 1.0.1. Seeded (not managed) — a refresh must never resurrect it once
16
+ * the first release has consumed it. */
17
+ export const EMPTY_CHANGESET_SEED = `---
18
+ ---
19
+
20
+ Initial release.
21
+ `;
22
+
23
+ const CHANGELOG = "@changesets/cli/changelog";
24
+ const ACCESS = "restricted";
25
+ const BASE_BRANCH = "main";
26
+ const UPDATE_INTERNAL_DEPENDENCIES = "patch";
27
+
28
+ const FIELD_ORDER = [
29
+ "$schema",
30
+ "changelog",
31
+ "commit",
32
+ "fixed",
33
+ "linked",
34
+ "access",
35
+ "baseBranch",
36
+ "updateInternalDependencies",
37
+ "ignore",
38
+ ];
39
+
40
+ export function rootChangesetConfigInitial(): Record<string, unknown> {
41
+ return {
42
+ $schema: "https://unpkg.com/@changesets/config@3.1.1/schema.json",
43
+ changelog: CHANGELOG,
44
+ commit: false,
45
+ fixed: [],
46
+ linked: [],
47
+ access: ACCESS,
48
+ baseBranch: BASE_BRANCH,
49
+ updateInternalDependencies: UPDATE_INTERNAL_DEPENDENCIES,
50
+ ignore: [],
51
+ };
52
+ }
53
+
54
+ export function rootChangesetConfigRules(): void {
55
+ ensureField("changelog", CHANGELOG);
56
+ ensureField("commit", false);
57
+ ensureField("access", ACCESS);
58
+ ensureField("baseBranch", BASE_BRANCH);
59
+ ensureField("updateInternalDependencies", UPDATE_INTERNAL_DEPENDENCIES);
60
+ enforceFieldOrder(FIELD_ORDER);
61
+ }
@@ -15,6 +15,7 @@ import {
15
15
  enforceFieldOrder,
16
16
  } from "../engine/api";
17
17
  import { canonicalPackageJsonOrder } from "./shared/key-order";
18
+ import { INITIAL_MODULE_VERSION } from "./shared/initial-version";
18
19
 
19
20
  // `variant=all` builds every variant the software declares — docker-vs-binary is not a per-script
20
21
  // choice. `no-software` builds placeholder software for validators.
@@ -42,7 +43,7 @@ export function rootPackageJsonInitial(): Record<string, unknown> {
42
43
  // No `name`: the root is never published. Body rules re-assert
43
44
  // (removeField("name")) as a drift-corrector.
44
45
  return {
45
- version: "1.0.0",
46
+ version: INITIAL_MODULE_VERSION,
46
47
  private: true,
47
48
  scripts: {
48
49
  fmt: "turbo run fmt",
@@ -30,7 +30,9 @@ export const SDK_CATALOG_PACKAGES: readonly string[] = [
30
30
  "@platforma-sdk/ui-vue",
31
31
  "@platforma-sdk/workflow-tengo",
32
32
  "@platforma-sdk/test",
33
+ "@platforma-sdk/block-kind",
33
34
  "@platforma-sdk/block-tools",
35
+ "@platforma-sdk/block-kind",
34
36
  "@platforma-sdk/tengo-builder",
35
37
  "@platforma-sdk/package-builder",
36
38
  "@milaboratories/ts-builder",
@@ -63,6 +65,7 @@ export const INFRA_CATALOG_FLOOR: Record<string, string> = {
63
65
  shx: "~0.4.0",
64
66
  "@changesets/cli": "~2.29.8",
65
67
  vitest: "~4.0.18",
68
+ typescript: "~5.9.3",
66
69
  };
67
70
 
68
71
  /** Catalog entries whose exact version is DERIVED from the version another
@@ -3,11 +3,16 @@
3
3
  // skip this whole scope: the parent monorepo owns root files. Hence the
4
4
  // `when(({ ctx }) => !ctx.isSdkInternal, ...)` wrapper.
5
5
 
6
- import { scope, when, fixed, managed, file, generate } from "../engine/api";
6
+ import { scope, when, fixed, managed, seed, file, text, generate } from "../engine/api";
7
7
  import { rootPackageJsonInitial, rootPackageJsonRules } from "./root-package-json";
8
8
  import { rootTurboJsonInitial, rootTurboJsonRules } from "./root-turbo-json";
9
9
  import { rootPnpmWorkspaceInitial, rootPnpmWorkspaceRules } from "./root-pnpm-workspace";
10
10
  import { rootGitignoreRules } from "./root-gitignore";
11
+ import {
12
+ rootChangesetConfigInitial,
13
+ rootChangesetConfigRules,
14
+ EMPTY_CHANGESET_SEED,
15
+ } from "./root-changeset-config";
11
16
 
12
17
  export function rootRules(): void {
13
18
  scope("root", () => {
@@ -42,6 +47,15 @@ export function rootRules(): void {
42
47
  managed(".gitignore", file("root/.gitignore"), () => {
43
48
  rootGitignoreRules();
44
49
  });
50
+
51
+ managed(
52
+ ".changeset/config.json",
53
+ generate(() => rootChangesetConfigInitial()),
54
+ () => {
55
+ rootChangesetConfigRules();
56
+ },
57
+ );
58
+ seed(".changeset/scaffold.md", text(EMPTY_CHANGESET_SEED));
45
59
  },
46
60
  );
47
61
  });