@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,189 @@
1
+ // JSON parser primitives — round trip, jsonPath set/get/has/delete,
2
+ // top-level key reorder behaviour.
3
+
4
+ import { describe, test, expect } from "vitest";
5
+ import {
6
+ parseJson,
7
+ stringifyJson,
8
+ getAtPath,
9
+ hasAtPath,
10
+ setAtPath,
11
+ deleteAtPath,
12
+ reorderTopLevel,
13
+ } from "../parsers/json";
14
+ import type { JsonObject } from "../parsers/json";
15
+
16
+ describe("JSON parser — round trip + jsonPath helpers", () => {
17
+ test("parse + stringify is stable on canonical input", () => {
18
+ const raw = '{\n "a": 1,\n "b": {\n "c": 2\n }\n}\n';
19
+ const parsed = parseJson(raw);
20
+ expect(stringifyJson(parsed)).toBe(raw);
21
+ });
22
+
23
+ test("stringify preserves source key order even when not alphabetical", () => {
24
+ // V8 / spec: JSON.parse + JSON.stringify preserve insertion order for
25
+ // string keys. The structurer's "no implicit ordering" guarantee
26
+ // depends on this — verify it here so a future runtime change is
27
+ // caught.
28
+ const raw = '{\n "z": 1,\n "a": 2,\n "m": 3\n}\n';
29
+ const parsed = parseJson(raw);
30
+ expect(Object.keys(parsed as object)).toEqual(["z", "a", "m"]);
31
+ expect(stringifyJson(parsed)).toBe(raw);
32
+ });
33
+
34
+ test("collapse: arrays within print width go on one line, wide ones break", () => {
35
+ // oxfmt's generic-.json shape (tsconfig et al.): the object expands one
36
+ // property per line while a short array collapses onto its line. The
37
+ // single-element `include` array — the case that regressed output to
38
+ // non-oxfmt-clean under JSON.stringify (which expanded it) — must collapse.
39
+ const tsconfig = { extends: "@milaboratories/ts-configs/block/model", include: ["src/**/*"] };
40
+ expect(stringifyJson(tsconfig, { collapse: true })).toBe(
41
+ '{\n "extends": "@milaboratories/ts-configs/block/model",\n "include": ["src/**/*"]\n}\n',
42
+ );
43
+ // A line wider than PRINT_WIDTH (100) breaks one element per line.
44
+ const x = "x".repeat(40);
45
+ const y = "y".repeat(40);
46
+ const z = "z".repeat(40);
47
+ const wide = { a: [x, y, z] };
48
+ expect(stringifyJson(wide, { collapse: true })).toBe(
49
+ `{\n "a": [\n "${x}",\n "${y}",\n "${z}"\n ]\n}\n`,
50
+ );
51
+ });
52
+
53
+ test("default (no collapse) keeps every container expanded — the package.json shape", () => {
54
+ // Without { collapse }, output is byte-identical to JSON.stringify(_, 2):
55
+ // package.json must stay fully expanded to match oxfmt's package.json
56
+ // formatting (which never collapses).
57
+ const value = { include: ["src/**/*"], deps: { a: "1.0.0" } };
58
+ expect(stringifyJson(value)).toBe(JSON.stringify(value, null, 2) + "\n");
59
+ });
60
+
61
+ test("collapse: empty containers stay compact, non-empty objects expand", () => {
62
+ // Empty `[]` / `{}` render inline; a non-empty object always breaks one
63
+ // property per line (oxfmt preserves the expanded object, arrays collapse
64
+ // by width).
65
+ expect(stringifyJson({ a: [], b: {}, c: { d: 1 } }, { collapse: true })).toBe(
66
+ '{\n "a": [],\n "b": {},\n "c": {\n "d": 1\n }\n}\n',
67
+ );
68
+ });
69
+
70
+ test("getAtPath / hasAtPath", () => {
71
+ const obj: JsonObject = { a: { b: { c: 42 } } };
72
+ expect(getAtPath(obj, "a.b.c")).toBe(42);
73
+ expect(getAtPath(obj, "a.b")).toEqual({ c: 42 });
74
+ expect(getAtPath(obj, "missing")).toBeUndefined();
75
+ expect(hasAtPath(obj, "a.b.c")).toBe(true);
76
+ expect(hasAtPath(obj, "a.b.z")).toBe(false);
77
+ });
78
+
79
+ test("setAtPath auto-creates intermediate objects", () => {
80
+ const obj: JsonObject = {};
81
+ setAtPath(obj, "a.b.c", "x");
82
+ expect(obj).toEqual({ a: { b: { c: "x" } } });
83
+ setAtPath(obj, "a.b.c", "y");
84
+ expect(obj).toEqual({ a: { b: { c: "y" } } });
85
+ });
86
+
87
+ test("setAtPath refuses to overwrite a non-object on the way down", () => {
88
+ const obj: JsonObject = { a: 1 };
89
+ expect(() => setAtPath(obj, "a.b", 2)).toThrow(/non-object/);
90
+ });
91
+
92
+ test("deleteAtPath removes the leaf, no-op on missing", () => {
93
+ const obj: JsonObject = { a: { b: 1, c: 2 } };
94
+ deleteAtPath(obj, "a.b");
95
+ expect(obj).toEqual({ a: { c: 2 } });
96
+ deleteAtPath(obj, "a.b");
97
+ expect(obj).toEqual({ a: { c: 2 } });
98
+ deleteAtPath(obj, "missing.path");
99
+ expect(obj).toEqual({ a: { c: 2 } });
100
+ });
101
+ });
102
+
103
+ describe("reorderTopLevel — known keys ordered, unknowns ride preceding known", () => {
104
+ const ORDER = ["name", "version", "private", "scripts", "dependencies", "pnpm"];
105
+
106
+ test("worked example preserves grouping", () => {
107
+ const input: JsonObject = {
108
+ name: "x",
109
+ private: true,
110
+ myCustomA: 1,
111
+ scripts: {},
112
+ myCustomB: 2,
113
+ dependencies: {},
114
+ };
115
+ const out = reorderTopLevel(input, ORDER);
116
+ expect(Object.keys(out)).toEqual([
117
+ "name",
118
+ "private",
119
+ "myCustomA",
120
+ "scripts",
121
+ "myCustomB",
122
+ "dependencies",
123
+ ]);
124
+ });
125
+
126
+ test("known keys move into declared order", () => {
127
+ const input: JsonObject = {
128
+ scripts: {},
129
+ private: true,
130
+ name: "x",
131
+ };
132
+ const out = reorderTopLevel(input, ORDER);
133
+ expect(Object.keys(out)).toEqual(["name", "private", "scripts"]);
134
+ });
135
+
136
+ test("unknown leading keys go first, in source order", () => {
137
+ const input: JsonObject = {
138
+ leading: 1,
139
+ another: 2,
140
+ name: "x",
141
+ };
142
+ const out = reorderTopLevel(input, ORDER);
143
+ expect(Object.keys(out)).toEqual(["leading", "another", "name"]);
144
+ });
145
+
146
+ test("idempotent — second reorder is a no-op", () => {
147
+ const input: JsonObject = {
148
+ name: "x",
149
+ private: true,
150
+ myCustom: 1,
151
+ scripts: {},
152
+ };
153
+ const once = reorderTopLevel(input, ORDER);
154
+ const twice = reorderTopLevel(once, ORDER);
155
+ expect(twice).toEqual(once);
156
+ });
157
+
158
+ test("reorder survives stringify → parse → stringify round-trip", () => {
159
+ // The structurer writes a managed file via stringifyJson(parsed)
160
+ // after reordering. A subsequent run parses that file again and
161
+ // must observe the same key order — otherwise the engine would
162
+ // report a diff and the post-run recheck would fail. Lock the
163
+ // contract here.
164
+ const input: JsonObject = {
165
+ scripts: { build: "x" },
166
+ private: true,
167
+ myCustomA: 1,
168
+ name: "demo",
169
+ myCustomB: 2,
170
+ dependencies: { foo: "1.0.0" },
171
+ };
172
+ const reordered = reorderTopLevel(input, ORDER);
173
+ const serialised = stringifyJson(reordered);
174
+ const reparsed = parseJson(serialised) as JsonObject;
175
+ // Source: scripts, private, myCustomA, name, myCustomB, dependencies.
176
+ // myCustomA followed `private` → rides private.
177
+ // myCustomB followed `name` → rides name.
178
+ expect(Object.keys(reparsed)).toEqual([
179
+ "name",
180
+ "myCustomB",
181
+ "private",
182
+ "myCustomA",
183
+ "scripts",
184
+ "dependencies",
185
+ ]);
186
+ // Second reorder + serialise must be byte-identical to the first.
187
+ expect(stringifyJson(reorderTopLevel(reparsed, ORDER))).toBe(serialised);
188
+ });
189
+ });
@@ -0,0 +1,44 @@
1
+ // Line-list parser — round trip, comment + whitespace normalisation,
2
+ // `containsEntry` semantics for .gitignore-style files.
3
+
4
+ import { describe, test, expect } from "vitest";
5
+ import {
6
+ parseLines,
7
+ serializeLines,
8
+ normaliseLine,
9
+ lineEqualsEntry,
10
+ containsEntry,
11
+ } from "../parsers/lines";
12
+
13
+ describe("line-list parser", () => {
14
+ test("round trip is stable on a canonical file", () => {
15
+ const raw = "a\nb\nc\n";
16
+ expect(serializeLines(parseLines(raw))).toBe(raw);
17
+ });
18
+
19
+ test("normaliseLine strips comments and whitespace", () => {
20
+ expect(normaliseLine(" node_modules/ # ignore deps")).toBe("node_modules/");
21
+ expect(normaliseLine("# pure comment")).toBe("");
22
+ expect(normaliseLine("dist/")).toBe("dist/");
23
+ expect(normaliseLine("")).toBe("");
24
+ });
25
+
26
+ test("lineEqualsEntry compares post-normalisation", () => {
27
+ expect(lineEqualsEntry(" dist/", "dist/")).toBe(true);
28
+ expect(lineEqualsEntry("dist/ # trailing", "dist/")).toBe(true);
29
+ expect(lineEqualsEntry("# dist/", "dist/")).toBe(false);
30
+ expect(lineEqualsEntry("dist/", "dist")).toBe(false);
31
+ });
32
+
33
+ test("containsEntry scans across a line list", () => {
34
+ const lines = parseLines("node_modules/\n# build artefacts\ndist/ # do not commit\n");
35
+ expect(containsEntry(lines, "dist/")).toBe(true);
36
+ expect(containsEntry(lines, "node_modules/")).toBe(true);
37
+ expect(containsEntry(lines, "missing/")).toBe(false);
38
+ });
39
+
40
+ test("missing trailing newline parses identically", () => {
41
+ expect(parseLines("a\nb")).toEqual(["a", "b"]);
42
+ expect(parseLines("a\nb\n")).toEqual(["a", "b"]);
43
+ });
44
+ });
@@ -0,0 +1,90 @@
1
+ // YAML parser primitives — round trip preserves comments around a
2
+ // catalog block (the load-bearing case for `pnpm-workspace.yaml`).
3
+ // Verifies get/set/has via jsonPath and that mutating one value leaves
4
+ // surrounding comments intact.
5
+
6
+ import { describe, test, expect } from "vitest";
7
+ import { parseYaml, stringifyYaml, yamlGet, yamlHas, yamlSet, yamlDelete } from "../parsers/yaml";
8
+
9
+ const CATALOG_YAML = `# pnpm workspace
10
+ packages:
11
+ - .
12
+ - block
13
+ - model
14
+
15
+ # Pinned SDK versions — bump via block-tools structure refresh --update-deps-only.
16
+ catalog:
17
+ # SDK packages — keep alphabetical for diff friendliness.
18
+ "@platforma-sdk/model": 1.2.3
19
+ # Build tooling.
20
+ "@milaboratories/ts-builder": 0.5.0
21
+ `;
22
+
23
+ describe("YAML parser — comment-preserving round trip", () => {
24
+ test("identity round trip preserves all comments", () => {
25
+ const doc = parseYaml(CATALOG_YAML);
26
+ const out = stringifyYaml(doc);
27
+ expect(out).toContain("# pnpm workspace");
28
+ expect(out).toContain("# Pinned SDK versions");
29
+ expect(out).toContain("# SDK packages — keep alphabetical for diff friendliness.");
30
+ expect(out).toContain("# Build tooling.");
31
+ });
32
+
33
+ test("mutating one catalog entry preserves surrounding comments", () => {
34
+ const doc = parseYaml(CATALOG_YAML);
35
+ yamlSet(doc, "catalog.@platforma-sdk/model", "9.9.9");
36
+ const out = stringifyYaml(doc);
37
+ expect(out).toContain('"@platforma-sdk/model": 9.9.9');
38
+ expect(out).toContain("# Pinned SDK versions");
39
+ expect(out).toContain("# SDK packages — keep alphabetical for diff friendliness.");
40
+ expect(out).toContain("# Build tooling.");
41
+ });
42
+
43
+ test("yamlGet / yamlHas read scalar values", () => {
44
+ const doc = parseYaml(CATALOG_YAML);
45
+ // "1.2.3" is parsed as a plain string (not a YAML number).
46
+ expect(yamlGet(doc, "catalog.@platforma-sdk/model")).toBe("1.2.3");
47
+ expect(yamlHas(doc, "catalog.@platforma-sdk/model")).toBe(true);
48
+ expect(yamlHas(doc, "catalog.nope")).toBe(false);
49
+ });
50
+
51
+ test("yamlDelete removes a key; round trip drops it", () => {
52
+ const doc = parseYaml(CATALOG_YAML);
53
+ yamlDelete(doc, "catalog.@milaboratories/ts-builder");
54
+ const out = stringifyYaml(doc);
55
+ expect(out).not.toContain("@milaboratories/ts-builder");
56
+ // Sibling entries untouched.
57
+ expect(out).toContain("@platforma-sdk/model");
58
+ });
59
+
60
+ test("parse → stringify on minimal scalar map", () => {
61
+ const doc = parseYaml("a: 1\nb: 2\n");
62
+ expect(stringifyYaml(doc)).toBe("a: 1\nb: 2\n");
63
+ });
64
+
65
+ test("yamlGet with empty jsonPath returns the whole document as JS", () => {
66
+ const doc = parseYaml("a: 1\n");
67
+ expect(yamlGet(doc, "")).toEqual({ a: 1 });
68
+ });
69
+
70
+ test("yamlHas with empty jsonPath returns true", () => {
71
+ const doc = parseYaml("a: 1\n");
72
+ expect(yamlHas(doc, "")).toBe(true);
73
+ });
74
+
75
+ test("yamlSet with empty jsonPath throws", () => {
76
+ const doc = parseYaml("a: 1\n");
77
+ expect(() => yamlSet(doc, "", "x")).toThrow(/jsonPath must be non-empty/);
78
+ });
79
+
80
+ test("yamlDelete with empty jsonPath throws", () => {
81
+ const doc = parseYaml("a: 1\n");
82
+ expect(() => yamlDelete(doc, "")).toThrow(/jsonPath must be non-empty/);
83
+ });
84
+
85
+ test("yamlSet auto-creates intermediate maps", () => {
86
+ const doc = parseYaml("a: 1\n");
87
+ yamlSet(doc, "nested.key", "v");
88
+ expect(yamlGet(doc, "nested.key")).toBe("v");
89
+ });
90
+ });
@@ -0,0 +1,99 @@
1
+ // Post-run recheck — a non-idempotent managed body must surface as a
2
+ // `RecheckError` carrying the failing item's (scope, path, primitive)
3
+ // tuple.
4
+
5
+ import { describe, test, expect } from "vitest";
6
+ import { defineStructure, scope, managed, text } from "../api";
7
+ import { ensureField } from "../content-rules";
8
+ import { createRunContext } from "../ctx";
9
+ import { MemoryFileSystem } from "../fs/memory";
10
+ import { run, RecheckError } from "../runner";
11
+
12
+ describe("post-run recheck", () => {
13
+ test("non-idempotent transform surfaces with (scope, path, primitive) tuple", async () => {
14
+ // A closure counter advances on every body invocation. First run
15
+ // writes counter=0; recheck dry-run sees the body produce
16
+ // counter=1 → non-empty diff → RecheckError.
17
+ let counter = 0;
18
+ const structure = defineStructure(() => {
19
+ scope("model", () => {
20
+ managed("drift.json", text("{}\n"), () => {
21
+ ensureField("counter", counter++);
22
+ });
23
+ });
24
+ });
25
+
26
+ const fs = new MemoryFileSystem();
27
+ const ctx = createRunContext({
28
+ blockVars: {
29
+ facadeName: "@platforma-open/demo",
30
+ baseName: "demo",
31
+ npmOrg: "@platforma-open",
32
+ orgScope: "demo",
33
+ shortName: "demo",
34
+ },
35
+ modules: [{ scope: "model", name: "@platforma-open/demo.model", path: "model" }],
36
+ });
37
+
38
+ let thrown: unknown;
39
+ try {
40
+ run(structure, fs, ctx);
41
+ } catch (e) {
42
+ thrown = e;
43
+ }
44
+ expect(thrown).toBeInstanceOf(RecheckError);
45
+ const err = thrown as RecheckError;
46
+ expect(err.failing.scope).toBe("model");
47
+ expect(err.failing.path).toBe("model/drift.json");
48
+ expect(err.failing.primitive).toBe("managed");
49
+ expect(err.message).toMatch(/not idempotent/);
50
+ });
51
+
52
+ test("recheck is silent when the rule set converges", async () => {
53
+ // Idempotent body — ensureField with a fixed value.
54
+ const structure = defineStructure(() => {
55
+ scope("model", () => {
56
+ managed("ok.json", text("{}\n"), () => {
57
+ ensureField("type", "module");
58
+ });
59
+ });
60
+ });
61
+ const fs = new MemoryFileSystem();
62
+ const ctx = createRunContext({
63
+ blockVars: {
64
+ facadeName: "@platforma-open/demo",
65
+ baseName: "demo",
66
+ npmOrg: "@platforma-open",
67
+ orgScope: "demo",
68
+ shortName: "demo",
69
+ },
70
+ modules: [{ scope: "model", name: "@platforma-open/demo.model", path: "model" }],
71
+ });
72
+ expect(run(structure, fs, ctx)).toBeDefined();
73
+ });
74
+
75
+ test("check mode skips recheck entirely", async () => {
76
+ let counter = 0;
77
+ const structure = defineStructure(() => {
78
+ scope("model", () => {
79
+ managed("drift.json", text("{}\n"), () => {
80
+ ensureField("counter", counter++);
81
+ });
82
+ });
83
+ });
84
+ const fs = new MemoryFileSystem();
85
+ const ctx = createRunContext({
86
+ blockVars: {
87
+ facadeName: "@platforma-open/demo",
88
+ baseName: "demo",
89
+ npmOrg: "@platforma-open",
90
+ orgScope: "demo",
91
+ shortName: "demo",
92
+ },
93
+ modules: [{ scope: "model", name: "@platforma-open/demo.model", path: "model" }],
94
+ dryRun: true,
95
+ });
96
+ // dryRun=true: no recheck, no throw, body only runs once.
97
+ expect(run(structure, fs, ctx)).toBeDefined();
98
+ });
99
+ });
@@ -0,0 +1,49 @@
1
+ // `buildRegistryLookupForNames` — the shared prefetch helper used by
2
+ // `init` (names from the SDK pin map) and `refresh --update-deps-only`
3
+ // (names from the on-disk catalog). Mocked client; no network.
4
+
5
+ import { describe, test, expect } from "vitest";
6
+ import {
7
+ buildRegistryLookupForNames,
8
+ matchesBumpPattern,
9
+ createMockRegistryClient,
10
+ } from "../registry-client";
11
+
12
+ describe("buildRegistryLookupForNames", () => {
13
+ test("empty name list → lookup resolves nothing, never touches the client", async () => {
14
+ // A client that would throw if asked — proves no call is made.
15
+ const throwing = createMockRegistryClient({});
16
+ const lookup = await buildRegistryLookupForNames([], throwing);
17
+ expect(lookup("@platforma-sdk/model")).toBeUndefined();
18
+ });
19
+
20
+ test("resolves each requested name to the client's latest", async () => {
21
+ const client = createMockRegistryClient({
22
+ "@platforma-sdk/model": "1.77.17",
23
+ "@platforma-sdk/test": "1.77.17",
24
+ });
25
+ const lookup = await buildRegistryLookupForNames(
26
+ ["@platforma-sdk/model", "@platforma-sdk/test"],
27
+ client,
28
+ );
29
+ expect(lookup("@platforma-sdk/model")).toBe("1.77.17");
30
+ expect(lookup("@platforma-sdk/test")).toBe("1.77.17");
31
+ expect(lookup("unknown")).toBeUndefined();
32
+ });
33
+
34
+ test("REJECTS when the registry fails — this is init's require-network abort path", async () => {
35
+ // Mock client throws for any unconfigured name (simulates registry down).
36
+ const down = createMockRegistryClient({});
37
+ await expect(buildRegistryLookupForNames(["@platforma-sdk/model"], down)).rejects.toThrow(
38
+ /no version configured/,
39
+ );
40
+ });
41
+
42
+ test("matchesBumpPattern selects the SDK families only", () => {
43
+ expect(matchesBumpPattern("@platforma-sdk/model")).toBe(true);
44
+ expect(matchesBumpPattern("@milaboratories/ts-builder")).toBe(true);
45
+ expect(matchesBumpPattern("turbo")).toBe(false);
46
+ expect(matchesBumpPattern("shx")).toBe(false);
47
+ expect(matchesBumpPattern("@platforma-open/milaboratories.runenv-python-3")).toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,136 @@
1
+ // End-to-end runner test: `when(...)` called inside a managed body
2
+ // receives the post-structural-pass TriggerContext from the runner.
3
+ //
4
+ // Scenario mirrors the compound-migration pattern: a rename creates
5
+ // `test-legacy/`; the managed body for `block/package.json` carries a
6
+ // `when(pathExists("test-legacy/"), ...)` block that rewrites the dep
7
+ // once the rename has landed.
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import { defineStructure, fixed, managed, rename, scope, text, generate, when } from "../api";
11
+ import type { Structure } from "../ir";
12
+ import { createRunContext } from "../ctx";
13
+ import { MemoryFileSystem } from "../fs/memory";
14
+ import { run } from "../runner";
15
+ import { ensureDep, ensureField, removeDep } from "../content-rules";
16
+
17
+ function migrationStructure(): Structure {
18
+ return defineStructure(() => {
19
+ // Migration: rename test/ → test-legacy/ once, idempotent via FS
20
+ // state. Wrapped in root scope (every leaf needs a scope frame).
21
+ scope("root", () => {
22
+ when(
23
+ ({ pathExists }) => pathExists("test/"),
24
+ () => {
25
+ rename("test/", "test-legacy/");
26
+ },
27
+ );
28
+ });
29
+
30
+ scope("block", () => {
31
+ fixed(".keep", text("ok\n"));
32
+ managed(
33
+ "package.json",
34
+ generate(() => ({ name: "demo.block", dependencies: { "old-test": "workspace:*" } })),
35
+ () => {
36
+ ensureField("type", "module");
37
+ // Inner when: rewrite dep target once the rename has landed.
38
+ // Runner builds the post-structural-pass snapshot, so
39
+ // `pathExists("test-legacy/")` is true on the same run.
40
+ when(
41
+ ({ pathExists }) => pathExists("test-legacy/"),
42
+ () => {
43
+ removeDep("old-test");
44
+ ensureDep("new-test", "workspace:*");
45
+ },
46
+ );
47
+ },
48
+ );
49
+ });
50
+ });
51
+ }
52
+
53
+ function ctxFor() {
54
+ return createRunContext({
55
+ blockVars: {
56
+ facadeName: "@platforma-open/demo",
57
+ baseName: "demo",
58
+ npmOrg: "@platforma-open",
59
+ orgScope: "demo",
60
+ shortName: "demo",
61
+ },
62
+ modules: [
63
+ { scope: "root", name: "@platforma-open/demo", path: "" },
64
+ { scope: "block", name: "@platforma-open/demo.block", path: "block" },
65
+ ],
66
+ });
67
+ }
68
+
69
+ describe("runner: inner when() sees post-structural-pass snapshot", () => {
70
+ test("rename + inner when fire together; deps rewritten in one refresh", async () => {
71
+ const fs = new MemoryFileSystem();
72
+ fs.write("test/a.ts", "// legacy");
73
+ fs.write(
74
+ "block/package.json",
75
+ JSON.stringify({ name: "demo.block", dependencies: { "old-test": "workspace:*" } }, null, 2) +
76
+ "\n",
77
+ );
78
+
79
+ const structure = migrationStructure();
80
+ run(structure, fs, ctxFor());
81
+
82
+ // Rename landed.
83
+ expect(fs.exists("test-legacy/a.ts")).toBe(true);
84
+ expect(fs.exists("test/a.ts")).toBe(false);
85
+
86
+ // Inner when fired → dep rewritten.
87
+ const pkgRaw = fs.read("block/package.json");
88
+ const pkg = JSON.parse(pkgRaw) as { dependencies: Record<string, string> };
89
+ expect(pkg.dependencies["old-test"]).toBeUndefined();
90
+ expect(pkg.dependencies["new-test"]).toBe("workspace:*");
91
+ });
92
+
93
+ test("second refresh is a no-op (idempotent)", async () => {
94
+ const fs = new MemoryFileSystem();
95
+ fs.write("test/a.ts", "// legacy");
96
+ fs.write(
97
+ "block/package.json",
98
+ JSON.stringify({ name: "demo.block", dependencies: { "old-test": "workspace:*" } }, null, 2) +
99
+ "\n",
100
+ );
101
+
102
+ const structure = migrationStructure();
103
+ run(structure, fs, ctxFor());
104
+ const snapshot1 = fs.read("block/package.json");
105
+
106
+ const result2 = run(structure, fs, ctxFor());
107
+ const snapshot2 = fs.read("block/package.json");
108
+
109
+ expect(snapshot2).toBe(snapshot1);
110
+ // No managed/structural changes the second time.
111
+ const managedOrStructural = result2.changes.filter(
112
+ (c) => c.primitive === "managed" || c.primitive === "rename",
113
+ );
114
+ expect(managedOrStructural).toEqual([]);
115
+ });
116
+
117
+ test("inner when skips when sibling path absent", async () => {
118
+ // No `test/` → no rename → no `test-legacy/` → inner when skips.
119
+ const fs = new MemoryFileSystem();
120
+ fs.write(
121
+ "block/package.json",
122
+ JSON.stringify({ name: "demo.block", dependencies: { "old-test": "workspace:*" } }, null, 2) +
123
+ "\n",
124
+ );
125
+
126
+ const structure = migrationStructure();
127
+ run(structure, fs, ctxFor());
128
+
129
+ const pkg = JSON.parse(fs.read("block/package.json")) as {
130
+ dependencies: Record<string, string>;
131
+ };
132
+ // Original dep survives unchanged.
133
+ expect(pkg.dependencies["old-test"]).toBe("workspace:*");
134
+ expect(pkg.dependencies["new-test"]).toBeUndefined();
135
+ });
136
+ });