@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,616 @@
1
+ // Content-rules DSL — builders that mutate the parsed representation
2
+ // of a `managed(path, initial, body)` file.
3
+ //
4
+ // Three active-state kinds correspond to three file flavours:
5
+ // - JSON object (package.json) → withManagedBody(obj, body)
6
+ // - YAML Document (pnpm-workspace.yaml) → withManagedYaml(doc, body)
7
+ // - line list (.gitignore) → withManagedLines(lines, body)
8
+ //
9
+ // Each builder verifies it is invoked under the matching active state
10
+ // and throws otherwise. The full inventory: JSON
11
+ // ensure*/remove*/require*/prune/enforce*/transform; YAML catalog
12
+ // management + workspace module paths; gitignore line management.
13
+
14
+ import { registerInnerWhenHandler, tryGetActiveRunContext } from "./builders";
15
+ import type { RunContext, Scope } from "./api";
16
+ import type { TriggerContext, TriggerFn } from "./ir";
17
+ import {
18
+ deleteAtPath,
19
+ getAtPath,
20
+ hasAtPath,
21
+ reorderTopLevel,
22
+ setAtPath,
23
+ type JsonObject,
24
+ } from "./parsers/json";
25
+ import { containsEntry, normaliseLine } from "./parsers/lines";
26
+ import type { YamlDocument } from "./parsers/yaml";
27
+
28
+ export type { JsonObject };
29
+
30
+ /** Allowed dep version strings. Specific versions live in the catalog.
31
+ * `"sdk:"` is an authoring abstraction, not an on-disk value: the engine
32
+ * resolves it at write time (`workspace:*` when `ctx.isSdkInternal`, else
33
+ * `"catalog:"`) and the resolved literal is what serializes. Use it for
34
+ * every dep that is a member of the platforma monorepo's pnpm workspace
35
+ * (the `@platforma-sdk/*` packages + `@milaboratories/ts-builder` /
36
+ * `ts-configs`). */
37
+ export type DepVersion = "catalog:" | "sdk:" | `workspace:${string}` | "*";
38
+
39
+ /** Resolve the `"sdk:"` sentinel to its on-disk literal. All other
40
+ * `DepVersion` forms pass through unchanged — `"sdk:"` never serializes. */
41
+ export function resolveDepVersion(
42
+ version: DepVersion,
43
+ isSdkInternal: boolean,
44
+ ): Exclude<DepVersion, "sdk:"> {
45
+ if (version === "sdk:") return isSdkInternal ? "workspace:*" : "catalog:";
46
+ return version;
47
+ }
48
+
49
+ const DEP_SECTION_KEYS = [
50
+ "dependencies",
51
+ "devDependencies",
52
+ "peerDependencies",
53
+ "optionalDependencies",
54
+ ] as const;
55
+
56
+ /** Resolve any `"sdk:"` sentinels in the dep sections of a generated
57
+ * package.json object, in place. Mirrors `resolveDepVersion` for the
58
+ * generator (`initial` content) path — the runner calls this on every
59
+ * `generate()`-produced `.json` value so the sentinel never reaches disk
60
+ * via a generator. */
61
+ export function resolveSdkSentinelsInJson(value: unknown, isSdkInternal: boolean): void {
62
+ if (!isObject(value)) return;
63
+ for (const section of DEP_SECTION_KEYS) {
64
+ const sec = value[section];
65
+ if (!isObject(sec)) continue;
66
+ for (const k of Object.keys(sec)) {
67
+ if (sec[k] === "sdk:") sec[k] = isSdkInternal ? "workspace:*" : "catalog:";
68
+ }
69
+ }
70
+ }
71
+
72
+ type JsonState = {
73
+ kind: "json";
74
+ obj: JsonObject;
75
+ ctx?: RunContext;
76
+ triggerContext?: TriggerContext;
77
+ };
78
+
79
+ type YamlState = {
80
+ kind: "yaml";
81
+ doc: YamlDocument;
82
+ ctx?: RunContext;
83
+ triggerContext?: TriggerContext;
84
+ /** Synchronous accessor for prefetched npm latest versions. */
85
+ getLatestVersion?: (packageName: string) => string | undefined;
86
+ };
87
+
88
+ type LinesState = {
89
+ kind: "lines";
90
+ lines: string[];
91
+ ctx?: RunContext;
92
+ triggerContext?: TriggerContext;
93
+ };
94
+
95
+ type ActiveState = JsonState | YamlState | LinesState;
96
+
97
+ let active: ActiveState | undefined;
98
+
99
+ function requireActive(builder: string): ActiveState {
100
+ if (!active) {
101
+ throw new Error(
102
+ `${builder}() only valid inside a managed(...) body. ` +
103
+ `Move this call inside managed(path, initial, () => {...}).`,
104
+ );
105
+ }
106
+ return active;
107
+ }
108
+
109
+ function requireJson(builder: string): JsonState {
110
+ const a = requireActive(builder);
111
+ if (a.kind !== "json") {
112
+ throw new Error(`${builder}() requires a JSON-managed body; got '${a.kind}'.`);
113
+ }
114
+ return a;
115
+ }
116
+
117
+ function requireYaml(builder: string): YamlState {
118
+ const a = requireActive(builder);
119
+ if (a.kind !== "yaml") {
120
+ throw new Error(`${builder}() requires a YAML-managed body; got '${a.kind}'.`);
121
+ }
122
+ return a;
123
+ }
124
+
125
+ function requireLines(builder: string): LinesState {
126
+ const a = requireActive(builder);
127
+ if (a.kind !== "lines") {
128
+ throw new Error(`${builder}() requires a lines-managed body; got '${a.kind}'.`);
129
+ }
130
+ return a;
131
+ }
132
+
133
+ function ctxOrThrow(state: ActiveState, builder: string): RunContext {
134
+ const c = state.ctx ?? tryGetActiveRunContext();
135
+ if (!c) {
136
+ throw new Error(
137
+ `${builder}() needs a RunContext but none is active. ` +
138
+ `Pass { ctx } via the with-managed-* helper or run inside engine.run(...).`,
139
+ );
140
+ }
141
+ return c;
142
+ }
143
+
144
+ function isObject(v: unknown): v is JsonObject {
145
+ return typeof v === "object" && v !== null && !Array.isArray(v);
146
+ }
147
+
148
+ // --- Active-context wrappers (used by the runner + Layer-1 helpers) ---
149
+
150
+ export type WithManagedOpts = {
151
+ ctx?: RunContext;
152
+ /** Trigger context for `when(...)` calls inside the body. The runner
153
+ * builds this from the post-structural-pass FS snapshot + run ctx.
154
+ * Without it, `when(...)` inside the body throws. */
155
+ triggerContext?: TriggerContext;
156
+ };
157
+
158
+ /** Engine-internal: run `body` with `obj` as the active JSON state so the
159
+ * JSON builders mutate it; returns the mutated object. Exported for tests —
160
+ * the runner calls this for managed `.json` files. */
161
+ export function withManagedBody(
162
+ obj: JsonObject,
163
+ body: () => void,
164
+ opts: WithManagedOpts = {},
165
+ ): JsonObject {
166
+ if (active) throw new Error("Nested managed(...) body — engine bug.");
167
+ active = { kind: "json", obj, ctx: opts.ctx, triggerContext: opts.triggerContext };
168
+ try {
169
+ body();
170
+ return obj;
171
+ } finally {
172
+ active = undefined;
173
+ }
174
+ }
175
+
176
+ export type WithManagedYamlOpts = WithManagedOpts & {
177
+ /** Sync accessor for prefetched latest versions. Required for
178
+ * `ensureCatalogLatest` to take effect. */
179
+ getLatestVersion?: (packageName: string) => string | undefined;
180
+ };
181
+
182
+ /** Engine-internal: run `body` with `doc` as the active YAML state (for
183
+ * `pnpm-workspace.yaml`); returns the mutated document. Exported for tests. */
184
+ export function withManagedYaml(
185
+ doc: YamlDocument,
186
+ body: () => void,
187
+ opts: WithManagedYamlOpts = {},
188
+ ): YamlDocument {
189
+ if (active) throw new Error("Nested managed(...) body — engine bug.");
190
+ active = {
191
+ kind: "yaml",
192
+ doc,
193
+ ctx: opts.ctx,
194
+ triggerContext: opts.triggerContext,
195
+ getLatestVersion: opts.getLatestVersion,
196
+ };
197
+ try {
198
+ body();
199
+ return doc;
200
+ } finally {
201
+ active = undefined;
202
+ }
203
+ }
204
+
205
+ /** Engine-internal: run `body` with `lines` as the active line-based state
206
+ * (for `.gitignore`); returns the mutated lines. Exported for tests. */
207
+ export function withManagedLines(
208
+ lines: string[],
209
+ body: () => void,
210
+ opts: WithManagedOpts = {},
211
+ ): string[] {
212
+ if (active) throw new Error("Nested managed(...) body — engine bug.");
213
+ active = {
214
+ kind: "lines",
215
+ lines,
216
+ ctx: opts.ctx,
217
+ triggerContext: opts.triggerContext,
218
+ };
219
+ try {
220
+ body();
221
+ return lines;
222
+ } finally {
223
+ active = undefined;
224
+ }
225
+ }
226
+
227
+ // --- Inner-when handler registration ---
228
+ //
229
+ // `when(...)` is exported from `builders.ts`. When the active tree is
230
+ // absent (we are inside a managed body, not inside `defineStructure`),
231
+ // it delegates here. The handler returns true iff there is an inner
232
+ // active state to dispatch against; the caller in `builders.ts` then
233
+ // treats the call as handled. Outer/inner dispatch lives in one
234
+ // exported `when` symbol — no separate inner-when builder.
235
+
236
+ registerInnerWhenHandler((trigger: TriggerFn, body: () => void): boolean => {
237
+ if (!active) return false;
238
+ const tctx = active.triggerContext;
239
+ if (!tctx) {
240
+ throw new Error(
241
+ "when() inside a managed(...) body requires a triggerContext. " +
242
+ "The runner supplies it via the `triggerContext` opt to with-managed-*; " +
243
+ "ensure your test/test helper passes one.",
244
+ );
245
+ }
246
+ if (trigger(tctx)) body();
247
+ return true;
248
+ });
249
+
250
+ // ============================================================
251
+ // JSON builders — `package.json` (and other JSON managed files)
252
+ // ============================================================
253
+
254
+ /** Set the value at `jsonPath` in the active managed JSON object. */
255
+ export function ensureField(jsonPath: string, value: unknown): void {
256
+ setAtPath(requireJson("ensureField").obj, jsonPath, value);
257
+ }
258
+
259
+ /** Remove the field at `jsonPath`. With `predicate`, only removes when
260
+ * the predicate returns true against the current value. */
261
+ export function removeField(
262
+ jsonPath: string,
263
+ predicate?: (currentValue: unknown) => boolean,
264
+ ): void {
265
+ const obj = requireJson("removeField").obj;
266
+ if (!hasAtPath(obj, jsonPath)) return;
267
+ if (predicate) {
268
+ const current = getAtPath(obj, jsonPath);
269
+ if (!predicate(current)) return;
270
+ }
271
+ deleteAtPath(obj, jsonPath);
272
+ }
273
+
274
+ /** Assert a field exists; throw if absent. No mutation. */
275
+ export function requireField(jsonPath: string, message?: string): void {
276
+ const obj = requireJson("requireField").obj;
277
+ if (!hasAtPath(obj, jsonPath)) {
278
+ throw new Error(message ?? `requireField: missing field '${jsonPath}'`);
279
+ }
280
+ }
281
+
282
+ /** Merge `entries` into the object at `jsonPath`. Auto-creates the
283
+ * object if missing. Existing other entries preserved. */
284
+ export function ensureFieldEntries(jsonPath: string, entries: Record<string, unknown>): void {
285
+ const obj = requireJson("ensureFieldEntries").obj;
286
+ const current = hasAtPath(obj, jsonPath) ? getAtPath(obj, jsonPath) : undefined;
287
+ const base: JsonObject = isObject(current) ? current : {};
288
+ for (const [k, v] of Object.entries(entries)) base[k] = v;
289
+ if (!isObject(current)) setAtPath(obj, jsonPath, base);
290
+ }
291
+
292
+ /** Set `scripts.<name>` to `command`. */
293
+ export function ensureScript(name: string, command: string): void {
294
+ ensureField(`scripts.${name}`, command);
295
+ }
296
+
297
+ /** Remove `scripts.<name>` if present. */
298
+ export function removeScript(name: string): void {
299
+ removeField(`scripts.${name}`);
300
+ }
301
+
302
+ // --- Dependency builders ---
303
+
304
+ const DEP_SECTIONS = [
305
+ "dependencies",
306
+ "devDependencies",
307
+ "peerDependencies",
308
+ "optionalDependencies",
309
+ ] as const;
310
+ type DepSection = (typeof DEP_SECTIONS)[number];
311
+
312
+ function ensureInSection(
313
+ builder: string,
314
+ name: string,
315
+ version: DepVersion,
316
+ section: DepSection,
317
+ ): void {
318
+ const state = requireJson(builder);
319
+ const obj = state.obj;
320
+ // Resolve the `sdk:` sentinel against the run mode before writing — it
321
+ // must never reach disk.
322
+ const resolved =
323
+ version === "sdk:"
324
+ ? resolveDepVersion(version, ctxOrThrow(state, builder).isSdkInternal)
325
+ : version;
326
+ // Single-section invariant: remove from any other section first.
327
+ for (const s of DEP_SECTIONS) {
328
+ if (s === section) continue;
329
+ const sec = obj[s];
330
+ if (isObject(sec) && name in sec) delete sec[name];
331
+ }
332
+ const existing = obj[section];
333
+ const target: JsonObject = isObject(existing) ? existing : {};
334
+ target[name] = resolved;
335
+ if (!isObject(existing)) obj[section] = target;
336
+ }
337
+
338
+ /** Pin `name` to `version` in `dependencies`. Enforces the single-section
339
+ * invariant — `name` may live in only one dependency section, so it is
340
+ * removed from the others first. A `sdk:` sentinel version is resolved
341
+ * against the run's `--sdk-internal` flag before it reaches disk. */
342
+ export function ensureDep(name: string, version: DepVersion): void {
343
+ ensureInSection("ensureDep", name, version, "dependencies");
344
+ }
345
+
346
+ /** Pin `name` to `version` in `devDependencies` (see {@link ensureDep} for the
347
+ * single-section + `sdk:` semantics). */
348
+ export function ensureDevDep(name: string, version: DepVersion): void {
349
+ ensureInSection("ensureDevDep", name, version, "devDependencies");
350
+ }
351
+
352
+ /** Pin `name` to `version` in `peerDependencies` (see {@link ensureDep}). */
353
+ export function ensurePeerDep(name: string, version: DepVersion): void {
354
+ ensureInSection("ensurePeerDep", name, version, "peerDependencies");
355
+ }
356
+
357
+ /** Pin `name` to `version` in `optionalDependencies` (see {@link ensureDep}). */
358
+ export function ensureOptionalDep(name: string, version: DepVersion): void {
359
+ ensureInSection("ensureOptionalDep", name, version, "optionalDependencies");
360
+ }
361
+
362
+ /** Remove `name` from every dependency section. */
363
+ export function removeDep(name: string): void {
364
+ const obj = requireJson("removeDep").obj;
365
+ for (const s of DEP_SECTIONS) {
366
+ const sec = obj[s];
367
+ if (isObject(sec) && name in sec) delete sec[name];
368
+ }
369
+ }
370
+
371
+ /** Bulk {@link ensureDep} for each `name → version` entry. */
372
+ export function ensureDeps(entries: Record<string, DepVersion>): void {
373
+ for (const [n, v] of Object.entries(entries)) ensureDep(n, v);
374
+ }
375
+
376
+ /** Bulk {@link ensureDevDep} for each `name → version` entry. */
377
+ export function ensureDevDeps(entries: Record<string, DepVersion>): void {
378
+ for (const [n, v] of Object.entries(entries)) ensureDevDep(n, v);
379
+ }
380
+
381
+ /** Bulk {@link ensurePeerDep} for each `name → version` entry. */
382
+ export function ensurePeerDeps(entries: Record<string, DepVersion>): void {
383
+ for (const [n, v] of Object.entries(entries)) ensurePeerDep(n, v);
384
+ }
385
+
386
+ /** Bulk {@link ensureOptionalDep} for each `name → version` entry. */
387
+ export function ensureOptionalDeps(entries: Record<string, DepVersion>): void {
388
+ for (const [n, v] of Object.entries(entries)) ensureOptionalDep(n, v);
389
+ }
390
+
391
+ function ensureWorkspaceScopeIn(builder: string, scope: Scope, section: DepSection): void {
392
+ const state = requireJson(builder);
393
+ const ctx = ctxOrThrow(state, builder);
394
+ for (const m of ctx.modules) {
395
+ if (m.scope === scope) {
396
+ ensureInSection(builder, m.name, "workspace:*", section);
397
+ }
398
+ }
399
+ }
400
+
401
+ /** Add every discovered module of `scope` as a `workspace:*` entry in
402
+ * `dependencies` — wires a block's intra-workspace links (e.g. the test
403
+ * scope depending on the other scopes). Single-section invariant per
404
+ * {@link ensureDep}. */
405
+ export function ensureWorkspaceScopeDeps(scope: Scope): void {
406
+ ensureWorkspaceScopeIn("ensureWorkspaceScopeDeps", scope, "dependencies");
407
+ }
408
+
409
+ /** Like {@link ensureWorkspaceScopeDeps}, into `devDependencies`. */
410
+ export function ensureWorkspaceScopeDevDeps(scope: Scope): void {
411
+ ensureWorkspaceScopeIn("ensureWorkspaceScopeDevDeps", scope, "devDependencies");
412
+ }
413
+
414
+ /** Like {@link ensureWorkspaceScopeDeps}, into `peerDependencies`. */
415
+ export function ensureWorkspaceScopePeerDeps(scope: Scope): void {
416
+ ensureWorkspaceScopeIn("ensureWorkspaceScopePeerDeps", scope, "peerDependencies");
417
+ }
418
+
419
+ // --- Prune ---
420
+
421
+ /** Delete every top-level key for which `predicate(key, value)` is true. */
422
+ export function pruneKeysMatching(predicate: (key: string, value: unknown) => boolean): void {
423
+ const obj = requireJson("pruneKeysMatching").obj;
424
+ for (const k of Object.keys(obj)) {
425
+ if (predicate(k, obj[k])) delete obj[k];
426
+ }
427
+ }
428
+
429
+ /** Delete every key of the object at `jsonPath` for which
430
+ * `predicate(key, value)` is true. No-op if the path is not an object. */
431
+ export function pruneKeysMatchingAt(
432
+ jsonPath: string,
433
+ predicate: (key: string, value: unknown) => boolean,
434
+ ): void {
435
+ const obj = requireJson("pruneKeysMatchingAt").obj;
436
+ const sub = getAtPath(obj, jsonPath);
437
+ if (!isObject(sub)) return;
438
+ for (const k of Object.keys(sub)) {
439
+ if (predicate(k, sub[k])) delete sub[k];
440
+ }
441
+ }
442
+
443
+ // --- Field order ---
444
+
445
+ function applyOrderInPlace(o: JsonObject, order: readonly string[]): void {
446
+ const reordered = reorderTopLevel(o, order);
447
+ for (const k of Object.keys(o)) delete o[k];
448
+ for (const k of Object.keys(reordered)) o[k] = reordered[k];
449
+ }
450
+
451
+ /** Reorder top-level keys to `orderedKeys`; keys not listed keep their
452
+ * position relative to the preceding listed key. Typically the LAST call in
453
+ * a managed body, projecting the canonical field order. */
454
+ export function enforceFieldOrder(orderedKeys: string[]): void {
455
+ applyOrderInPlace(requireJson("enforceFieldOrder").obj, orderedKeys);
456
+ }
457
+
458
+ /** Like {@link enforceFieldOrder}, applied to the object at `jsonPath`. No-op
459
+ * if the path is not an object. */
460
+ export function enforceFieldOrderAt(jsonPath: string, orderedKeys: string[]): void {
461
+ const obj = requireJson("enforceFieldOrderAt").obj;
462
+ const sub = getAtPath(obj, jsonPath);
463
+ if (!isObject(sub)) return;
464
+ applyOrderInPlace(sub, orderedKeys);
465
+ }
466
+
467
+ function sortAlphabeticalInPlace(o: JsonObject, recursive: boolean): void {
468
+ const sorted = Object.keys(o).sort();
469
+ const buf: JsonObject = {};
470
+ for (const k of sorted) buf[k] = o[k];
471
+ for (const k of Object.keys(o)) delete o[k];
472
+ for (const k of sorted) {
473
+ o[k] = buf[k];
474
+ if (recursive && isObject(o[k])) sortAlphabeticalInPlace(o[k] as JsonObject, true);
475
+ }
476
+ }
477
+
478
+ /** Sort keys alphabetically. Called with no args it is a deliberate no-op
479
+ * (guards against accidental whole-file sorts); pass a `jsonPath` to sort one
480
+ * object, or `{ recursive: true }` to sort the whole tree. Matches oxfmt's
481
+ * dependency-section ordering. */
482
+ export function enforceAlphabeticalOrder(
483
+ jsonPath: string = "",
484
+ opts: { recursive?: boolean } = {},
485
+ ): void {
486
+ const recursive = opts.recursive ?? false;
487
+ // Default jsonPath="" with recursive:false is a no-op (prevents accidents).
488
+ if (jsonPath === "" && !recursive) return;
489
+ const obj = requireJson("enforceAlphabeticalOrder").obj;
490
+ if (jsonPath === "") {
491
+ sortAlphabeticalInPlace(obj, true);
492
+ return;
493
+ }
494
+ const sub = getAtPath(obj, jsonPath);
495
+ if (!isObject(sub)) return;
496
+ sortAlphabeticalInPlace(sub, recursive);
497
+ }
498
+
499
+ // --- Generic transform escape hatch ---
500
+
501
+ /** Escape hatch: replace the value at `jsonPath` with `transform(current)`,
502
+ * for shapes the typed builders above do not cover. */
503
+ export function transformAt<T = unknown>(jsonPath: string, transform: (current: T) => T): void {
504
+ const obj = requireJson("transformAt").obj;
505
+ const current = getAtPath(obj, jsonPath) as T;
506
+ const next = transform(current);
507
+ setAtPath(obj, jsonPath, next);
508
+ }
509
+
510
+ // ============================================================
511
+ // YAML builders — `pnpm-workspace.yaml`
512
+ // ============================================================
513
+
514
+ /** Set the workspace `packages:` list to all discovered NON-root module
515
+ * paths (sorted lex). The block root ("") is discovered implicitly
516
+ * and is NEVER written to `packages:` — listing "." there breaks turbo's
517
+ * task graph. Equality-guarded — skips the rewrite when the existing
518
+ * list already matches, so a YAML round-trip on an already-canonical
519
+ * file produces zero churn. */
520
+ export function ensureWorkspaceModulePaths(): void {
521
+ const state = requireYaml("ensureWorkspaceModulePaths");
522
+ const ctx = ctxOrThrow(state, "ensureWorkspaceModulePaths");
523
+ const paths = ctx.modules
524
+ .filter((m) => m.path !== "")
525
+ .map((m) => m.path)
526
+ .sort();
527
+ const json = state.doc.toJSON() as { packages?: unknown } | null;
528
+ const cur = json?.packages;
529
+ if (Array.isArray(cur) && cur.length === paths.length && cur.every((v, i) => v === paths[i])) {
530
+ return;
531
+ }
532
+ state.doc.setIn(["packages"], paths);
533
+ }
534
+
535
+ function readCatalogString(state: YamlState, name: string): string | undefined {
536
+ const node = state.doc.getIn(["catalog", name]);
537
+ return typeof node === "string" ? node : undefined;
538
+ }
539
+
540
+ /** Add `catalog.<name>` at `version` ONLY if the key is absent. An entry
541
+ * the block already carries is left untouched — no overwrite, no
542
+ * downgrade. This is the seeding primitive for curated fixed versions
543
+ * (`INFRA_CATALOG_FLOOR`, the python runenv pin): on
544
+ * `refresh --update-deps-only` a missing standard catalog key is added,
545
+ * while a key the block already pins keeps its own version. To force a
546
+ * specific version regardless of what is present, use `pinCatalogTo`. */
547
+ export function ensureCatalogVersion(name: string, version: string): void {
548
+ const state = requireYaml("ensureCatalogVersion");
549
+ if (readCatalogString(state, name) !== undefined) return;
550
+ state.doc.setIn(["catalog", name], version);
551
+ }
552
+
553
+ /** Set `catalog.<name>` to `version` EXACTLY — creates the entry if
554
+ * missing, overwrites it (including a downgrade) if present. Use to force
555
+ * a specific version; with top-to-bottom declaration order a later
556
+ * `pinCatalogTo` overrides an earlier `ensureCatalogLatest`. Distinct
557
+ * from `ensureCatalogVersion`, which never touches a present key. */
558
+ export function pinCatalogTo(name: string, version: string): void {
559
+ const state = requireYaml("pinCatalogTo");
560
+ if (readCatalogString(state, name) === version) return;
561
+ state.doc.setIn(["catalog", name], version);
562
+ }
563
+
564
+ /** Resolve `catalog.<name>` to npm "latest" from the active state's
565
+ * prefetched `getLatestVersion` accessor (the caller pre-resolves; no
566
+ * network here), creating the key if absent. No-op when no latest is
567
+ * available — default refresh passes no lookup, and a name that was not
568
+ * prefetched resolves to `undefined`; in both cases any existing entry is
569
+ * left untouched. This is the SDK-family resolver: on
570
+ * `refresh --update-deps-only` it both SEEDS a missing standard SDK key
571
+ * and refreshes a present one, and on `init` it overwrites the
572
+ * placeholder with the real latest. */
573
+ export function ensureCatalogLatest(name: string): void {
574
+ const state = requireYaml("ensureCatalogLatest");
575
+ if (!state.getLatestVersion) return;
576
+ const latest = state.getLatestVersion(name);
577
+ if (latest === undefined) return;
578
+ if (readCatalogString(state, name) === latest) return;
579
+ state.doc.setIn(["catalog", name], latest);
580
+ }
581
+
582
+ /** Remove `catalog.<name>` if present; no-op when absent. */
583
+ export function ensureCatalogAbsent(name: string): void {
584
+ const state = requireYaml("ensureCatalogAbsent");
585
+ if (!state.doc.hasIn(["catalog", name])) return;
586
+ state.doc.deleteIn(["catalog", name]);
587
+ }
588
+
589
+ // ============================================================
590
+ // Lines builders — `.gitignore`
591
+ // ============================================================
592
+
593
+ /** Append each `entry` if not already present (comment-aware equality). */
594
+ export function ensureGitignoreEntries(entries: string[]): void {
595
+ const state = requireLines("ensureGitignoreEntries");
596
+ for (const e of entries) {
597
+ if (containsEntry(state.lines, e)) continue;
598
+ state.lines.push(e);
599
+ }
600
+ }
601
+
602
+ /** Drop any line whose normalised form matches any of `patterns`. */
603
+ export function removeGitignoreEntries(patterns: RegExp[]): void {
604
+ const state = requireLines("removeGitignoreEntries");
605
+ const kept: string[] = [];
606
+ for (const line of state.lines) {
607
+ const n = normaliseLine(line);
608
+ if (n === "") {
609
+ kept.push(line);
610
+ continue;
611
+ }
612
+ if (patterns.some((p) => p.test(n))) continue;
613
+ kept.push(line);
614
+ }
615
+ state.lines.splice(0, state.lines.length, ...kept);
616
+ }