@platforma-sdk/block-tools 2.10.13 → 2.10.15

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 (169) hide show
  1. package/dist/cli.js +23 -2
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +1944 -100
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/cmd/index.d.ts +10 -4
  6. package/dist/cmd/structure/check.d.ts +10 -0
  7. package/dist/cmd/structure/init.d.ts +18 -0
  8. package/dist/cmd/structure/refresh.d.ts +11 -0
  9. package/dist/cmd/update-deps.d.ts +7 -0
  10. package/dist/structure/cli/run-structure.d.ts +19 -0
  11. package/dist/structure/engine/api.d.ts +55 -0
  12. package/dist/structure/engine/builders.d.ts +95 -0
  13. package/dist/structure/engine/content-rules.d.ts +148 -0
  14. package/dist/structure/engine/ctx.d.ts +34 -0
  15. package/dist/structure/engine/discovery-fs.d.ts +22 -0
  16. package/dist/structure/engine/discovery.d.ts +39 -0
  17. package/dist/structure/engine/flatten.d.ts +3 -0
  18. package/dist/structure/engine/fs/api.d.ts +27 -0
  19. package/dist/structure/engine/fs/memory.d.ts +16 -0
  20. package/dist/structure/engine/fs/node.d.ts +14 -0
  21. package/dist/structure/engine/glob.d.ts +4 -0
  22. package/dist/structure/engine/ir.d.ts +124 -0
  23. package/dist/structure/engine/parsers/json.d.ts +37 -0
  24. package/dist/structure/engine/parsers/lines.d.ts +9 -0
  25. package/dist/structure/engine/parsers/yaml.d.ts +18 -0
  26. package/dist/structure/engine/registry-client.d.ts +34 -0
  27. package/dist/structure/engine/runner.d.ts +45 -0
  28. package/dist/structure/engine/templates.d.ts +30 -0
  29. package/dist/structure/engine/testing.d.ts +34 -0
  30. package/dist/structure/engine/version.d.ts +18 -0
  31. package/dist/structure/init-block-constructor.d.ts +32 -0
  32. package/dist/structure/rules/block-package-json.d.ts +9 -0
  33. package/dist/structure/rules/block.d.ts +1 -0
  34. package/dist/structure/rules/migrations.d.ts +2 -0
  35. package/dist/structure/rules/model-package-json.d.ts +3 -0
  36. package/dist/structure/rules/model.d.ts +1 -0
  37. package/dist/structure/rules/root-catalog-bump.d.ts +1 -0
  38. package/dist/structure/rules/root-gitignore.d.ts +2 -0
  39. package/dist/structure/rules/root-package-json.d.ts +3 -0
  40. package/dist/structure/rules/root-pnpm-workspace.d.ts +15 -0
  41. package/dist/structure/rules/root.d.ts +1 -0
  42. package/dist/structure/rules/shared/colocated-tests.d.ts +2 -0
  43. package/dist/structure/rules/shared/key-order.d.ts +1 -0
  44. package/dist/structure/rules/software-package-json.d.ts +3 -0
  45. package/dist/structure/rules/software.d.ts +1 -0
  46. package/dist/structure/rules/test-package-json.d.ts +3 -0
  47. package/dist/structure/rules/test.d.ts +1 -0
  48. package/dist/structure/rules/ui-package-json.d.ts +3 -0
  49. package/dist/structure/rules/ui.d.ts +1 -0
  50. package/dist/structure/rules/workflow-package-json.d.ts +3 -0
  51. package/dist/structure/rules/workflow.d.ts +1 -0
  52. package/dist/structure/structure-definition.d.ts +1 -0
  53. package/package.json +8 -6
  54. package/src/cmd/index.ts +10 -4
  55. package/src/cmd/structure/check.ts +51 -0
  56. package/src/cmd/structure/init.ts +65 -0
  57. package/src/cmd/structure/refresh.ts +44 -0
  58. package/src/cmd/update-deps.ts +15 -3
  59. package/src/structure/__tests__/colocated-test-wiring.test.ts +107 -0
  60. package/src/structure/__tests__/floor-version.test.ts +52 -0
  61. package/src/structure/__tests__/init-catalog-resolution.test.ts +82 -0
  62. package/src/structure/__tests__/init-check-parity.test.ts +59 -0
  63. package/src/structure/__tests__/model-package-json.snapshot.test.ts +60 -0
  64. package/src/structure/__tests__/oxfmt-clean-init.test.ts +64 -0
  65. package/src/structure/__tests__/root-catalog-bump.test.ts +110 -0
  66. package/src/structure/__tests__/seed-disjointness.test.ts +79 -0
  67. package/src/structure/cli/run-structure.ts +89 -0
  68. package/src/structure/engine/__tests__/content-rules/active-state-guards.test.ts +61 -0
  69. package/src/structure/engine/__tests__/content-rules/catalog-pin.test.ts +46 -0
  70. package/src/structure/engine/__tests__/content-rules/enforce-alphabetical-order.test.ts +55 -0
  71. package/src/structure/engine/__tests__/content-rules/enforce-field-order.test.ts +73 -0
  72. package/src/structure/engine/__tests__/content-rules/ensure-catalog-absent.test.ts +29 -0
  73. package/src/structure/engine/__tests__/content-rules/ensure-catalog-latest.test.ts +70 -0
  74. package/src/structure/engine/__tests__/content-rules/ensure-dep.test.ts +63 -0
  75. package/src/structure/engine/__tests__/content-rules/ensure-deps-bulk.test.ts +46 -0
  76. package/src/structure/engine/__tests__/content-rules/ensure-field-entries.test.ts +35 -0
  77. package/src/structure/engine/__tests__/content-rules/ensure-field.test.ts +56 -0
  78. package/src/structure/engine/__tests__/content-rules/ensure-script.test.ts +39 -0
  79. package/src/structure/engine/__tests__/content-rules/ensure-workspace-scope-deps.test.ts +92 -0
  80. package/src/structure/engine/__tests__/content-rules/gitignore.test.ts +50 -0
  81. package/src/structure/engine/__tests__/content-rules/pin-catalog-to.test.ts +64 -0
  82. package/src/structure/engine/__tests__/content-rules/prune-keys.test.ts +59 -0
  83. package/src/structure/engine/__tests__/content-rules/registry-client.test.ts +116 -0
  84. package/src/structure/engine/__tests__/content-rules/remove-field.test.ts +49 -0
  85. package/src/structure/engine/__tests__/content-rules/require-field.test.ts +40 -0
  86. package/src/structure/engine/__tests__/content-rules/transform-at.test.ts +33 -0
  87. package/src/structure/engine/__tests__/content-rules/when-inner.test.ts +244 -0
  88. package/src/structure/engine/__tests__/content-rules/workspace-module-paths.test.ts +53 -0
  89. package/src/structure/engine/__tests__/discovery.test.ts +160 -0
  90. package/src/structure/engine/__tests__/fs-memory.test.ts +59 -0
  91. package/src/structure/engine/__tests__/glob.test.ts +43 -0
  92. package/src/structure/engine/__tests__/parsers-json.test.ts +189 -0
  93. package/src/structure/engine/__tests__/parsers-lines.test.ts +44 -0
  94. package/src/structure/engine/__tests__/parsers-yaml.test.ts +90 -0
  95. package/src/structure/engine/__tests__/recheck.test.ts +99 -0
  96. package/src/structure/engine/__tests__/registry-lookup.test.ts +49 -0
  97. package/src/structure/engine/__tests__/runner-when-inside-managed.test.ts +136 -0
  98. package/src/structure/engine/__tests__/runner.test.ts +136 -0
  99. package/src/structure/engine/__tests__/synthetic.test.ts +153 -0
  100. package/src/structure/engine/__tests__/version.test.ts +44 -0
  101. package/src/structure/engine/__tests__/when-files-exist.test.ts +89 -0
  102. package/src/structure/engine/api.ts +131 -0
  103. package/src/structure/engine/builders.ts +341 -0
  104. package/src/structure/engine/content-rules.ts +616 -0
  105. package/src/structure/engine/ctx.ts +94 -0
  106. package/src/structure/engine/discovery-fs.ts +209 -0
  107. package/src/structure/engine/discovery.ts +168 -0
  108. package/src/structure/engine/flatten.ts +117 -0
  109. package/src/structure/engine/fs/api.ts +40 -0
  110. package/src/structure/engine/fs/memory.ts +132 -0
  111. package/src/structure/engine/fs/node.ts +89 -0
  112. package/src/structure/engine/glob.ts +42 -0
  113. package/src/structure/engine/ir.ts +149 -0
  114. package/src/structure/engine/parsers/json.ts +212 -0
  115. package/src/structure/engine/parsers/lines.ts +42 -0
  116. package/src/structure/engine/parsers/yaml.ts +72 -0
  117. package/src/structure/engine/registry-client.ts +140 -0
  118. package/src/structure/engine/runner.ts +519 -0
  119. package/src/structure/engine/templates.ts +74 -0
  120. package/src/structure/engine/testing.ts +93 -0
  121. package/src/structure/engine/version.ts +60 -0
  122. package/src/structure/init-block-constructor.ts +164 -0
  123. package/src/structure/rules/block-package-json.ts +156 -0
  124. package/src/structure/rules/block.ts +29 -0
  125. package/src/structure/rules/migrations.ts +63 -0
  126. package/src/structure/rules/model-package-json.ts +123 -0
  127. package/src/structure/rules/model.ts +65 -0
  128. package/src/structure/rules/root-catalog-bump.ts +77 -0
  129. package/src/structure/rules/root-gitignore.ts +27 -0
  130. package/src/structure/rules/root-package-json.ts +119 -0
  131. package/src/structure/rules/root-pnpm-workspace.ts +103 -0
  132. package/src/structure/rules/root.ts +43 -0
  133. package/src/structure/rules/shared/colocated-tests.ts +11 -0
  134. package/src/structure/rules/shared/key-order.ts +34 -0
  135. package/src/structure/rules/software-package-json.ts +86 -0
  136. package/src/structure/rules/software.ts +33 -0
  137. package/src/structure/rules/test-package-json.ts +70 -0
  138. package/src/structure/rules/test.ts +40 -0
  139. package/src/structure/rules/ui-package-json.ts +108 -0
  140. package/src/structure/rules/ui.ts +75 -0
  141. package/src/structure/rules/workflow-package-json.ts +92 -0
  142. package/src/structure/rules/workflow.ts +51 -0
  143. package/src/structure/structure-definition.ts +28 -0
  144. package/src/structure/templates/static/block/index.d.ts +6 -0
  145. package/src/structure/templates/static/block/index.js +8 -0
  146. package/src/structure/templates/static/block/logos/block-logo.png +0 -0
  147. package/src/structure/templates/static/block/logos/organization-logo.png +0 -0
  148. package/src/structure/templates/static/model/.oxfmtrc.json +4 -0
  149. package/src/structure/templates/static/model/.oxlintrc.json +3 -0
  150. package/src/structure/templates/static/model/src/index.ts +13 -0
  151. package/src/structure/templates/static/model/tsconfig.json +4 -0
  152. package/src/structure/templates/static/root/.gitignore +13 -0
  153. package/src/structure/templates/static/root/.vscode/settings.json +3 -0
  154. package/src/structure/templates/static/root/turbo.json +35 -0
  155. package/src/structure/templates/static/test/tsconfig.json +5 -0
  156. package/src/structure/templates/static/test/vitest.config.mts +8 -0
  157. package/src/structure/templates/static/ui/.oxfmtrc.json +3 -0
  158. package/src/structure/templates/static/ui/.oxlintrc.json +3 -0
  159. package/src/structure/templates/static/ui/index.html +12 -0
  160. package/src/structure/templates/static/ui/src/main.ts +5 -0
  161. package/src/structure/templates/static/ui/tsconfig.json +4 -0
  162. package/src/structure/templates/static/workflow/format.el +42 -0
  163. package/src/structure/templates/static/workflow/tsconfig.json +16 -0
  164. package/src/structure/templates/static/workflow/vitest.config.mts +9 -0
  165. package/src/structure/templates/text/README.md +5 -0
  166. package/src/structure/templates/text/docs/description.md +3 -0
  167. package/src/structure/templates/text/ui/src/MainPage.tpl.vue +9 -0
  168. package/src/structure/templates/text/ui/src/app.tpl.ts +11 -0
  169. package/src/structure/templates/text/workflow/src/main.tpl.tengo +10 -0
@@ -0,0 +1,110 @@
1
+ // The `onInitOrUpdate` frame in `rootCatalogBumpRules` resolves the SDK
2
+ // catalog to npm latest. It must fire on `init` AND `--update-deps-only`,
3
+ // but NOT on a default refresh; default-mode leaves are the inverse (fire
4
+ // on init + default refresh, skipped under --update-deps-only). Mocked
5
+ // registry — no network.
6
+
7
+ import { describe, test, expect } from "vitest";
8
+ import { parse as parseYaml } from "yaml";
9
+ import { defineStructure, scope, fixed, text } from "../engine/api";
10
+ import { rootCatalogBumpRules } from "../rules/root-catalog-bump";
11
+ import { run as engineRun } from "../engine/runner";
12
+ import { MemoryFileSystem } from "../engine/fs/memory";
13
+ import { createRunContext } from "../engine/ctx";
14
+ import type { BlockVars } from "../engine/api";
15
+
16
+ const VARS: BlockVars = {
17
+ facadeName: "@platforma-open/test-org.demo",
18
+ baseName: "test-org.demo",
19
+ npmOrg: "@platforma-open",
20
+ orgScope: "test-org",
21
+ shortName: "demo",
22
+ };
23
+
24
+ // A normal-mode leaf (MARKER.txt) sits alongside the update-deps frame so
25
+ // we can prove the two modes are mutually exclusive.
26
+ const STRUCTURE = defineStructure(() => {
27
+ scope("root", () => {
28
+ fixed("MARKER.txt", text("marker\n"));
29
+ });
30
+ rootCatalogBumpRules();
31
+ });
32
+
33
+ const WORKSPACE_YAML = [
34
+ "packages:",
35
+ " - .",
36
+ "catalog:",
37
+ " '@platforma-sdk/model': 1.0.0",
38
+ " '@milaboratories/ts-builder': 1.0.0",
39
+ " lodash: 1.0.0",
40
+ "",
41
+ ].join("\n");
42
+
43
+ function freshFs(): MemoryFileSystem {
44
+ return new MemoryFileSystem({ "pnpm-workspace.yaml": WORKSPACE_YAML });
45
+ }
46
+
47
+ function ctx(updateDepsOnly: boolean) {
48
+ return createRunContext({
49
+ blockVars: VARS,
50
+ modules: [{ scope: "root", name: VARS.facadeName, path: "" }],
51
+ isSdkInternal: false,
52
+ updateDepsOnly,
53
+ version: 1,
54
+ dryRun: false,
55
+ });
56
+ }
57
+
58
+ const MOCK_LATEST: Record<string, string> = {
59
+ "@platforma-sdk/model": "9.9.9",
60
+ "@milaboratories/ts-builder": "9.9.9",
61
+ };
62
+ const mockLookup = (name: string): string | undefined => MOCK_LATEST[name];
63
+
64
+ describe("rootCatalogBumpRules mode split", () => {
65
+ test("default refresh: catalog untouched, default-mode leaf fires", async () => {
66
+ const fs = freshFs();
67
+ engineRun(STRUCTURE, fs, ctx(false), { registryLookup: mockLookup });
68
+
69
+ const cat = (parseYaml(fs.read("pnpm-workspace.yaml")) as { catalog: Record<string, string> })
70
+ .catalog;
71
+ expect(cat["@platforma-sdk/model"]).toBe("1.0.0");
72
+ expect(cat["@milaboratories/ts-builder"]).toBe("1.0.0");
73
+ // Default-mode leaf did fire.
74
+ expect(fs.exists("MARKER.txt")).toBe(true);
75
+ });
76
+
77
+ test("--update-deps-only: SDK catalog bumped, infra floor seeded, default-mode leaf skipped", async () => {
78
+ const fs = freshFs();
79
+ engineRun(STRUCTURE, fs, ctx(true), { registryLookup: mockLookup });
80
+
81
+ const cat = (parseYaml(fs.read("pnpm-workspace.yaml")) as { catalog: Record<string, string> })
82
+ .catalog;
83
+ // Present SDK keys refreshed to latest.
84
+ expect(cat["@platforma-sdk/model"]).toBe("9.9.9");
85
+ expect(cat["@milaboratories/ts-builder"]).toBe("9.9.9");
86
+ // standard infra-floor keys ABSENT from the on-disk catalog are
87
+ // SEEDED add-if-absent (no manual seed step needed).
88
+ expect(cat["shx"]).toBe("~0.4.0");
89
+ expect(cat["turbo"]).toBe("~2.8.11");
90
+ expect(cat["vitest"]).toBe("~4.0.18");
91
+ // Non-matching dep left alone.
92
+ expect(cat["lodash"]).toBe("1.0.0");
93
+ // Default-mode leaf was skipped in update-deps-only mode.
94
+ expect(fs.exists("MARKER.txt")).toBe(false);
95
+ });
96
+
97
+ test("init: SDK catalog bumped AND default-mode leaf fires", async () => {
98
+ const fs = freshFs();
99
+ engineRun(STRUCTURE, fs, ctx(false), { registryLookup: mockLookup, initMode: true });
100
+
101
+ const cat = (parseYaml(fs.read("pnpm-workspace.yaml")) as { catalog: Record<string, string> })
102
+ .catalog;
103
+ // onInitOrUpdate fired → SDK bumped.
104
+ expect(cat["@platforma-sdk/model"]).toBe("9.9.9");
105
+ expect(cat["@milaboratories/ts-builder"]).toBe("9.9.9");
106
+ expect(cat["lodash"]).toBe("1.0.0");
107
+ // Default-mode leaf also fired on init.
108
+ expect(fs.exists("MARKER.txt")).toBe(true);
109
+ });
110
+ });
@@ -0,0 +1,79 @@
1
+ // Validation: a path cannot be declared as both `seed` and any
2
+ // engine-managed primitive (`fixed` / `managed` / `scaffold`) within
3
+ // the same scope.
4
+
5
+ import { describe, expect, test } from "vitest";
6
+ import { defineStructure, scope, seed, managed, fixed, scaffold, text, file } from "../engine/api";
7
+
8
+ describe("seed/managed disjointness", () => {
9
+ test("seed + managed on same path throws", () => {
10
+ expect(() =>
11
+ defineStructure(() => {
12
+ scope("model", () => {
13
+ seed("src/index.ts", text("// seed\n"));
14
+ managed("src/index.ts", file("model/index.ts"), () => {});
15
+ });
16
+ }),
17
+ ).toThrow(/path 'src\/index\.ts'.*both seed and/);
18
+ });
19
+
20
+ test("seed + fixed on same path throws", () => {
21
+ expect(() =>
22
+ defineStructure(() => {
23
+ scope("ui", () => {
24
+ fixed("index.html", file("ui/index.html"));
25
+ seed("index.html", text("<!doctype html>\n"));
26
+ });
27
+ }),
28
+ ).toThrow(/path 'index\.html'.*both seed and/);
29
+ });
30
+
31
+ test("seed + scaffold on same path throws", () => {
32
+ expect(() =>
33
+ defineStructure(() => {
34
+ scope("workflow", () => {
35
+ scaffold("README.md", text("# seed README\n"));
36
+ seed("README.md", text("# seed README\n"));
37
+ });
38
+ }),
39
+ ).toThrow(/path 'README\.md'.*both seed and/);
40
+ });
41
+
42
+ test("seed on its own (no collision) is fine", () => {
43
+ expect(() =>
44
+ defineStructure(() => {
45
+ scope("model", () => {
46
+ seed("src/index.ts", text("// seed only\n"));
47
+ });
48
+ }),
49
+ ).not.toThrow();
50
+ });
51
+
52
+ test("fixed + managed on same path is fine (no seed)", () => {
53
+ // This is a structural mistake of a different kind (fixed and
54
+ // managed both write to the same path) but is NOT in scope of
55
+ // this validation — the seed disjointness rule is specifically
56
+ // about seed contracts. Other collisions surface at runtime.
57
+ expect(() =>
58
+ defineStructure(() => {
59
+ scope("model", () => {
60
+ fixed("same.json", text("a\n"));
61
+ managed("same.json", text("a\n"), () => {});
62
+ });
63
+ }),
64
+ ).not.toThrow();
65
+ });
66
+
67
+ test("seed and managed on DIFFERENT scopes with the same relative path is fine", () => {
68
+ expect(() =>
69
+ defineStructure(() => {
70
+ scope("model", () => {
71
+ seed("src/index.ts", text("// model seed\n"));
72
+ });
73
+ scope("ui", () => {
74
+ managed("src/index.ts", text("// ui managed\n"), () => {});
75
+ });
76
+ }),
77
+ ).not.toThrow();
78
+ });
79
+ });
@@ -0,0 +1,89 @@
1
+ // Shared driver for the `structure check` and `structure refresh`
2
+ // oclif commands. Wraps DISCOVERY (discover-fs) + the engine runner for
3
+ // one block path, handling the standalone vs `--sdk-internal` split, the
4
+ // `--update-deps-only` registry prefetch, and the post-run recheck's
5
+ // fresh re-discovery. Each block path is processed independently (no
6
+ // cross-block state).
7
+
8
+ import path from "node:path";
9
+ import { NodeFileSystem } from "../engine/fs/node";
10
+ import { NodeTemplateProvider } from "../engine/templates";
11
+ import { run as engineRun, type Change } from "../engine/runner";
12
+ import { discoverRunContext } from "../engine/discovery-fs";
13
+ import { STRUCTURE } from "../structure-definition";
14
+ import { matchesBumpPattern, buildRegistryLookupForNames } from "../engine/registry-client";
15
+ import { SDK_CATALOG_PACKAGES } from "../rules/root-pnpm-workspace";
16
+
17
+ export type StructureMode = "check" | "refresh";
18
+
19
+ export type RunStructureInput = {
20
+ /** User-supplied block path (resolved against cwd). */
21
+ blockPath: string;
22
+ isSdkInternal: boolean;
23
+ updateDepsOnly: boolean;
24
+ mode: StructureMode;
25
+ /** `<block-tools>/src/structure/templates` (from oclif `config.root`). */
26
+ templatesRoot: string;
27
+ log: (msg: string) => void;
28
+ };
29
+
30
+ export type RunStructureResult = {
31
+ blockPath: string;
32
+ changes: Change[];
33
+ };
34
+
35
+ /** Resolve the registry lookup for `--update-deps-only`: prefetch npm
36
+ * "latest" for the SDK catalog MEMBERSHIP list (`SDK_CATALOG_PACKAGES`),
37
+ * not the on-disk catalog keys, so the sync `ensureCatalogLatest` builder
38
+ * can SEED a standard SDK key the block does not yet carry — reading
39
+ * the on-disk catalog could never resolve an absent key. Mirrors
40
+ * the `runInit` prefetch. A network failure during prefetch propagates. */
41
+ async function buildRegistryLookup(): Promise<(name: string) => string | undefined> {
42
+ // Every SDK_CATALOG_PACKAGES entry matches a bump pattern (enforced at
43
+ // load time in root-pnpm-workspace); filter defensively all the same.
44
+ const names = SDK_CATALOG_PACKAGES.filter(matchesBumpPattern);
45
+ return buildRegistryLookupForNames(names);
46
+ }
47
+
48
+ export async function runStructureForPath(input: RunStructureInput): Promise<RunStructureResult> {
49
+ const root = path.resolve(input.blockPath);
50
+ const dryRun = input.mode === "check";
51
+
52
+ const fs = new NodeFileSystem(root);
53
+ const templates = new NodeTemplateProvider(input.templatesRoot);
54
+
55
+ const ctx = discoverRunContext({
56
+ fs,
57
+ isSdkInternal: input.isSdkInternal,
58
+ updateDepsOnly: input.updateDepsOnly,
59
+ dryRun,
60
+ });
61
+
62
+ const registryLookup = input.updateDepsOnly ? await buildRegistryLookup() : undefined;
63
+
64
+ const result = engineRun(STRUCTURE, fs, ctx, {
65
+ templates,
66
+ registryLookup,
67
+ // Fresh re-discovery for the post-run recheck (default refresh only)
68
+ // — re-reads the just-written tree so a rename that shifted the
69
+ // module map is observed.
70
+ rediscover: () =>
71
+ discoverRunContext({
72
+ fs,
73
+ isSdkInternal: input.isSdkInternal,
74
+ updateDepsOnly: false,
75
+ dryRun: true,
76
+ }),
77
+ });
78
+
79
+ return { blockPath: input.blockPath, changes: result.changes };
80
+ }
81
+
82
+ /** One-line-per-change summary for CLI output. */
83
+ export function formatChanges(blockPath: string, changes: Change[]): string {
84
+ if (changes.length === 0) return ` ${blockPath}: up to date (0 changes)`;
85
+ const lines = changes.map(
86
+ (c) => ` ${c.action.padEnd(6)} ${c.primitive.padEnd(8)} ${c.path} [${c.scope}]`,
87
+ );
88
+ return ` ${blockPath}: ${changes.length} change(s)\n${lines.join("\n")}`;
89
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ ensureField,
4
+ ensureGitignoreEntries,
5
+ ensureCatalogVersion,
6
+ withManagedBody,
7
+ withManagedYaml,
8
+ } from "../../content-rules";
9
+ import { parseYaml } from "../../parsers/yaml";
10
+
11
+ describe("active-state guards", () => {
12
+ test("JSON-only builder throws inside a YAML body", () => {
13
+ const doc = parseYaml("catalog: {}\n");
14
+ expect(() =>
15
+ withManagedYaml(doc, () => {
16
+ ensureField("type", "module");
17
+ }),
18
+ ).toThrow(/requires a JSON-managed body/);
19
+ });
20
+
21
+ test("YAML-only builder throws inside a JSON body", () => {
22
+ expect(() =>
23
+ withManagedBody({}, () => {
24
+ ensureCatalogVersion("yaml", "1.0.0");
25
+ }),
26
+ ).toThrow(/requires a YAML-managed body/);
27
+ });
28
+
29
+ test("lines-only builder throws inside a JSON body", () => {
30
+ expect(() =>
31
+ withManagedBody({}, () => {
32
+ ensureGitignoreEntries(["node_modules/"]);
33
+ }),
34
+ ).toThrow(/requires a lines-managed body/);
35
+ });
36
+
37
+ test("nested managed bodies are rejected", () => {
38
+ expect(() =>
39
+ withManagedBody({}, () => {
40
+ withManagedBody({}, () => {
41
+ ensureField("x", 1);
42
+ });
43
+ }),
44
+ ).toThrow(/Nested managed/);
45
+ });
46
+
47
+ test("builder called outside any body throws a clear message", () => {
48
+ expect(() => ensureCatalogVersion("y", "1")).toThrow(/only valid inside a managed/);
49
+ });
50
+
51
+ test("active state is reset after a body throws", () => {
52
+ expect(() =>
53
+ withManagedBody({}, () => {
54
+ throw new Error("boom");
55
+ }),
56
+ ).toThrow(/boom/);
57
+ // If the active state had leaked, the next call would think it's
58
+ // still inside a body.
59
+ expect(() => ensureField("x", 1)).toThrow(/only valid inside a managed/);
60
+ });
61
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { ensureCatalogVersion, pinCatalogTo, withManagedYaml } from "../../content-rules";
3
+ import { parseYaml, stringifyYaml } from "../../parsers/yaml";
4
+
5
+ describe("ensureCatalogVersion (add-if-absent) / pinCatalogTo (set-exact)", () => {
6
+ test("ensureCatalogVersion leaves a present entry untouched (no-downgrade)", () => {
7
+ const doc = parseYaml("catalog:\n yaml: 2.5.0\n");
8
+ // A version is already present — add-if-absent must NOT overwrite or
9
+ // downgrade it.
10
+ withManagedYaml(doc, () => ensureCatalogVersion("yaml", "2.6.0"));
11
+ expect((doc.toJSON() as { catalog: Record<string, string> }).catalog.yaml).toBe("2.5.0");
12
+ });
13
+
14
+ test("ensureCatalogVersion creates the entry if missing", () => {
15
+ const doc = parseYaml("catalog: {}\n");
16
+ withManagedYaml(doc, () => ensureCatalogVersion("yaml", "2.5.0"));
17
+ expect((doc.toJSON() as { catalog: Record<string, string> }).catalog.yaml).toBe("2.5.0");
18
+ });
19
+
20
+ test("pinCatalogTo overwrites a present entry (set-exact, downgrade-capable)", () => {
21
+ const doc = parseYaml("catalog:\n yaml: 2.5.0\n");
22
+ withManagedYaml(doc, () => pinCatalogTo("yaml", "3.0.0"));
23
+ expect((doc.toJSON() as { catalog: Record<string, string> }).catalog.yaml).toBe("3.0.0");
24
+ });
25
+
26
+ test("pinCatalogTo creates the entry if missing", () => {
27
+ const doc = parseYaml("catalog: {}\n");
28
+ withManagedYaml(doc, () => pinCatalogTo("yaml", "1.2.3"));
29
+ expect((doc.toJSON() as { catalog: Record<string, string> }).catalog.yaml).toBe("1.2.3");
30
+ });
31
+
32
+ test("idempotent — double-run produces deep-equal output", () => {
33
+ const doc = parseYaml("catalog:\n a: 1.0.0\n b: 2.0.0\n");
34
+ withManagedYaml(doc, () => {
35
+ ensureCatalogVersion("a", "1.5.0");
36
+ ensureCatalogVersion("b", "2.1.0");
37
+ });
38
+ const once = stringifyYaml(doc);
39
+ withManagedYaml(doc, () => {
40
+ ensureCatalogVersion("a", "1.5.0");
41
+ ensureCatalogVersion("b", "2.1.0");
42
+ });
43
+ const twice = stringifyYaml(doc);
44
+ expect(twice).toBe(once);
45
+ });
46
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { enforceAlphabeticalOrder, withManagedBody, type JsonObject } from "../../content-rules";
3
+
4
+ function run(input: JsonObject, body: () => void): JsonObject {
5
+ return withManagedBody(input, body);
6
+ }
7
+
8
+ describe("enforceAlphabeticalOrder", () => {
9
+ test("default form (no args) is a no-op (safety)", () => {
10
+ const out = run({ b: 1, a: 2 }, () => enforceAlphabeticalOrder());
11
+ expect(Object.keys(out)).toEqual(["b", "a"]);
12
+ });
13
+
14
+ test("sorts keys at a specific nested path", () => {
15
+ const out = run({ scripts: { test: "x", build: "y", fmt: "z" } }, () =>
16
+ enforceAlphabeticalOrder("scripts"),
17
+ );
18
+ expect(Object.keys(out.scripts as Record<string, unknown>)).toEqual(["build", "fmt", "test"]);
19
+ });
20
+
21
+ test("recursive sorts nested objects too", () => {
22
+ const out = run(
23
+ {
24
+ outer: { z: 1, a: { c: 1, b: 2 } },
25
+ },
26
+ () => enforceAlphabeticalOrder("outer", { recursive: true }),
27
+ );
28
+ expect(Object.keys(out.outer as Record<string, unknown>)).toEqual(["a", "z"]);
29
+ const a = (out.outer as { a: Record<string, unknown> }).a;
30
+ expect(Object.keys(a)).toEqual(["b", "c"]);
31
+ });
32
+
33
+ test("root recursive sorts entire object", () => {
34
+ const out = run({ b: { z: 1, a: 2 }, a: { y: 1, x: 2 } }, () =>
35
+ enforceAlphabeticalOrder("", { recursive: true }),
36
+ );
37
+ expect(Object.keys(out)).toEqual(["a", "b"]);
38
+ expect(Object.keys(out.a as Record<string, unknown>)).toEqual(["x", "y"]);
39
+ expect(Object.keys(out.b as Record<string, unknown>)).toEqual(["a", "z"]);
40
+ });
41
+
42
+ test("no-op when the path is missing or not an object", () => {
43
+ const out = run({ name: "x" }, () => enforceAlphabeticalOrder("scripts"));
44
+ expect(out).toEqual({ name: "x" });
45
+ });
46
+
47
+ test("idempotent — second run is deep-equal to the first", () => {
48
+ const once = run({ scripts: { c: 1, a: 2, b: 3 } }, () => enforceAlphabeticalOrder("scripts"));
49
+ const twice = run(JSON.parse(JSON.stringify(once)), () => enforceAlphabeticalOrder("scripts"));
50
+ expect(twice).toEqual(once);
51
+ expect(Object.keys(twice.scripts as Record<string, unknown>)).toEqual(
52
+ Object.keys(once.scripts as Record<string, unknown>),
53
+ );
54
+ });
55
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ enforceFieldOrder,
4
+ enforceFieldOrderAt,
5
+ withManagedBody,
6
+ type JsonObject,
7
+ } from "../../content-rules";
8
+
9
+ function run(input: JsonObject, body: () => void): JsonObject {
10
+ return withManagedBody(input, body);
11
+ }
12
+
13
+ const order = ["name", "version", "private", "scripts", "dependencies", "pnpm"];
14
+
15
+ describe("enforceFieldOrder / enforceFieldOrderAt", () => {
16
+ test("reorders known keys; unknowns stay adjacent to their preceding known key", () => {
17
+ const out = run(
18
+ {
19
+ name: "x",
20
+ private: true,
21
+ myCustomA: 1,
22
+ scripts: {},
23
+ myCustomB: 2,
24
+ dependencies: {},
25
+ },
26
+ () => enforceFieldOrder(order),
27
+ );
28
+ expect(Object.keys(out)).toEqual([
29
+ "name",
30
+ "private",
31
+ "myCustomA",
32
+ "scripts",
33
+ "myCustomB",
34
+ "dependencies",
35
+ ]);
36
+ });
37
+
38
+ test("missing known keys are skipped", () => {
39
+ const out = run({ name: "x", dependencies: {} }, () => enforceFieldOrder(order));
40
+ expect(Object.keys(out)).toEqual(["name", "dependencies"]);
41
+ });
42
+
43
+ test("unknowns before any known key go first in source order", () => {
44
+ const out = run({ foo: 1, bar: 2, name: "x" }, () => enforceFieldOrder(order));
45
+ expect(Object.keys(out)).toEqual(["foo", "bar", "name"]);
46
+ });
47
+
48
+ test("enforceFieldOrderAt reorders a nested object", () => {
49
+ const out = run(
50
+ {
51
+ scripts: {
52
+ build: "x",
53
+ test: "y",
54
+ fmt: "z",
55
+ },
56
+ },
57
+ () => enforceFieldOrderAt("scripts", ["fmt", "test", "build"]),
58
+ );
59
+ expect(Object.keys(out.scripts as Record<string, unknown>)).toEqual(["fmt", "test", "build"]);
60
+ });
61
+
62
+ test("enforceFieldOrderAt is a no-op when path missing", () => {
63
+ const out = run({ name: "x" }, () => enforceFieldOrderAt("scripts", ["a"]));
64
+ expect(out).toEqual({ name: "x" });
65
+ });
66
+
67
+ test("idempotent — second run produces deep-equal output", () => {
68
+ const once = run({ name: "x", scripts: {}, type: "module" }, () => enforceFieldOrder(order));
69
+ const twice = run(JSON.parse(JSON.stringify(once)), () => enforceFieldOrder(order));
70
+ expect(twice).toEqual(once);
71
+ expect(Object.keys(twice)).toEqual(Object.keys(once));
72
+ });
73
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { ensureCatalogAbsent, withManagedYaml } from "../../content-rules";
3
+ import { parseYaml, stringifyYaml } from "../../parsers/yaml";
4
+
5
+ describe("ensureCatalogAbsent", () => {
6
+ test("removes a present entry, leaves other keys alone", () => {
7
+ const doc = parseYaml("catalog:\n drop-me: 1.0.0\n keep: 2.0.0\n");
8
+ withManagedYaml(doc, () => ensureCatalogAbsent("drop-me"));
9
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
10
+ expect(cat["drop-me"]).toBeUndefined();
11
+ expect(cat.keep).toBe("2.0.0");
12
+ });
13
+
14
+ test("no-op when the key is absent", () => {
15
+ const doc = parseYaml("catalog:\n keep: 2.0.0\n");
16
+ withManagedYaml(doc, () => ensureCatalogAbsent("never-here"));
17
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
18
+ expect(cat.keep).toBe("2.0.0");
19
+ });
20
+
21
+ test("idempotent — second run produces the same YAML", () => {
22
+ const doc = parseYaml("catalog:\n drop-me: 1.0.0\n keep: 2.0.0\n");
23
+ withManagedYaml(doc, () => ensureCatalogAbsent("drop-me"));
24
+ const once = stringifyYaml(doc);
25
+ withManagedYaml(doc, () => ensureCatalogAbsent("drop-me"));
26
+ const twice = stringifyYaml(doc);
27
+ expect(twice).toBe(once);
28
+ });
29
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { ensureCatalogLatest, withManagedYaml } from "../../content-rules";
3
+ import { parseYaml, stringifyYaml } from "../../parsers/yaml";
4
+ import {
5
+ createMockRegistryClient,
6
+ makeSyncLookup,
7
+ prefetchLatestVersions,
8
+ } from "../../registry-client";
9
+
10
+ async function lookupFor(versions: Record<string, string>) {
11
+ const client = createMockRegistryClient(versions);
12
+ const resolved = await prefetchLatestVersions(client, Object.keys(versions));
13
+ return makeSyncLookup(resolved);
14
+ }
15
+
16
+ describe("ensureCatalogLatest (mocked registry)", () => {
17
+ test("overwrites a present entry with the resolved latest", async () => {
18
+ const doc = parseYaml("catalog:\n '@platforma-sdk/model': 1.0.0\n");
19
+ const getLatestVersion = await lookupFor({ "@platforma-sdk/model": "2.0.0" });
20
+ withManagedYaml(doc, () => ensureCatalogLatest("@platforma-sdk/model"), { getLatestVersion });
21
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
22
+ expect(cat["@platforma-sdk/model"]).toBe("2.0.0");
23
+ });
24
+
25
+ test("add-if-absent: seeds a missing key from the resolved latest", async () => {
26
+ const doc = parseYaml("catalog:\n unrelated: 9.9.9\n");
27
+ const getLatestVersion = await lookupFor({ "@milaboratories/ts-builder": "3.4.5" });
28
+ withManagedYaml(doc, () => ensureCatalogLatest("@milaboratories/ts-builder"), {
29
+ getLatestVersion,
30
+ });
31
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
32
+ expect(cat["@milaboratories/ts-builder"]).toBe("3.4.5");
33
+ expect(cat.unrelated).toBe("9.9.9");
34
+ });
35
+
36
+ test("no-op when getLatestVersion is not provided (default refresh)", () => {
37
+ const doc = parseYaml("catalog:\n '@platforma-sdk/model': 1.0.0\n");
38
+ withManagedYaml(doc, () => ensureCatalogLatest("@platforma-sdk/model"));
39
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
40
+ expect(cat["@platforma-sdk/model"]).toBe("1.0.0");
41
+ });
42
+
43
+ test("no-op when the name was not prefetched (lookup returns undefined)", async () => {
44
+ const doc = parseYaml("catalog:\n '@platforma-sdk/model': 1.0.0\n");
45
+ const getLatestVersion = await lookupFor({ "@platforma-sdk/other": "2.0.0" });
46
+ // The present key has no prefetched latest → left untouched, and an
47
+ // absent unprefetched key is not added.
48
+ withManagedYaml(
49
+ doc,
50
+ () => {
51
+ ensureCatalogLatest("@platforma-sdk/model");
52
+ ensureCatalogLatest("@platforma-sdk/missing");
53
+ },
54
+ { getLatestVersion },
55
+ );
56
+ const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
57
+ expect(cat["@platforma-sdk/model"]).toBe("1.0.0");
58
+ expect(cat["@platforma-sdk/missing"]).toBeUndefined();
59
+ });
60
+
61
+ test("idempotent — second run produces the same YAML", async () => {
62
+ const doc = parseYaml("catalog:\n '@platforma-sdk/model': 1.0.0\n");
63
+ const getLatestVersion = await lookupFor({ "@platforma-sdk/model": "2.0.0" });
64
+ withManagedYaml(doc, () => ensureCatalogLatest("@platforma-sdk/model"), { getLatestVersion });
65
+ const once = stringifyYaml(doc);
66
+ withManagedYaml(doc, () => ensureCatalogLatest("@platforma-sdk/model"), { getLatestVersion });
67
+ const twice = stringifyYaml(doc);
68
+ expect(twice).toBe(once);
69
+ });
70
+ });