@platforma-sdk/block-tools 2.10.14 → 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 +9 -7
  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,132 @@
1
+ // In-memory FS implementation. Files are stored as a flat
2
+ // `Map<path, string>`; directories exist implicitly when any contained
3
+ // file exists. Used by tests, by `simulateInit`, and by the runner's
4
+ // post-run recheck dry-run pass.
5
+
6
+ import type { DirEntry, FileSystem } from "./api";
7
+
8
+ function normalise(p: string): string {
9
+ // Collapse repeated slashes, strip leading "./" and trailing "/".
10
+ let out = p.replace(/\/{2,}/g, "/");
11
+ if (out.startsWith("./")) out = out.slice(2);
12
+ if (out.length > 1 && out.endsWith("/")) out = out.slice(0, -1);
13
+ return out;
14
+ }
15
+
16
+ export class MemoryFileSystem implements FileSystem {
17
+ private files = new Map<string, string | Uint8Array>();
18
+
19
+ constructor(initial?: Record<string, string>) {
20
+ if (initial) {
21
+ for (const [k, v] of Object.entries(initial)) {
22
+ this.files.set(normalise(k), v);
23
+ }
24
+ }
25
+ }
26
+
27
+ /** Snapshot of all files — handy for assertions. Binary entries are
28
+ * rendered as a placeholder so the snapshot stays a string map. */
29
+ snapshot(): Record<string, string> {
30
+ const out: Record<string, string> = {};
31
+ for (const [k, v] of [...this.files.entries()].sort(([a], [b]) => a.localeCompare(b))) {
32
+ out[k] = typeof v === "string" ? v : `<binary:${v.byteLength} bytes>`;
33
+ }
34
+ return out;
35
+ }
36
+
37
+ read(path: string): string {
38
+ const n = normalise(path);
39
+ const v = this.files.get(n);
40
+ if (v === undefined) throw new Error(`ENOENT: ${path}`);
41
+ if (typeof v !== "string") throw new Error(`read: ${path} is a binary file`);
42
+ return v;
43
+ }
44
+
45
+ write(path: string, content: string): void {
46
+ this.files.set(normalise(path), content);
47
+ }
48
+
49
+ writeBinary(path: string, content: Uint8Array): void {
50
+ this.files.set(normalise(path), content);
51
+ }
52
+
53
+ exists(path: string): boolean {
54
+ const n = normalise(path);
55
+ if (this.files.has(n)) return true;
56
+ // Directory if any file is under `n/`.
57
+ const prefix = n.endsWith("/") ? n : `${n}/`;
58
+ for (const k of this.files.keys()) {
59
+ if (k.startsWith(prefix)) return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ list(dir: string): string[] {
65
+ const n = normalise(dir);
66
+ const prefix = n === "" ? "" : `${n}/`;
67
+ const out: string[] = [];
68
+ for (const k of this.files.keys()) {
69
+ if (n === "" || k.startsWith(prefix)) out.push(k);
70
+ }
71
+ return out.sort();
72
+ }
73
+
74
+ listDir(dir: string): DirEntry[] {
75
+ const n = normalise(dir);
76
+ const prefix = n === "" ? "" : `${n}/`;
77
+ // Map immediate child name → isDirectory (a child is a directory if
78
+ // the remainder after the first segment still contains a "/").
79
+ const children = new Map<string, boolean>();
80
+ for (const k of this.files.keys()) {
81
+ if (n !== "" && !k.startsWith(prefix)) continue;
82
+ const rest = n === "" ? k : k.slice(prefix.length);
83
+ if (rest === "") continue;
84
+ const slash = rest.indexOf("/");
85
+ if (slash < 0) {
86
+ children.set(rest, children.get(rest) ?? false);
87
+ } else {
88
+ children.set(rest.slice(0, slash), true);
89
+ }
90
+ }
91
+ return [...children.entries()]
92
+ .map(([name, isDirectory]) => ({ name, isDirectory }))
93
+ .sort((a, b) => a.name.localeCompare(b.name));
94
+ }
95
+
96
+ move(from: string, to: string): void {
97
+ const fromN = normalise(from);
98
+ const toN = normalise(to);
99
+ const fromPrefix = `${fromN}/`;
100
+ const toPrefix = `${toN}/`;
101
+ const moved: Array<[string, string]> = [];
102
+ if (this.files.has(fromN)) {
103
+ moved.push([fromN, toN]);
104
+ }
105
+ for (const k of this.files.keys()) {
106
+ if (k.startsWith(fromPrefix)) {
107
+ moved.push([k, toPrefix + k.slice(fromPrefix.length)]);
108
+ }
109
+ }
110
+ if (moved.length === 0) {
111
+ throw new Error(`move: source missing: ${from}`);
112
+ }
113
+ if (this.exists(to)) {
114
+ throw new Error(`move: dest exists: ${to}`);
115
+ }
116
+ for (const [src, dst] of moved) {
117
+ this.files.set(dst, this.files.get(src)!);
118
+ this.files.delete(src);
119
+ }
120
+ }
121
+
122
+ delete(path: string): void {
123
+ const n = normalise(path);
124
+ const prefix = `${n}/`;
125
+ this.files.delete(n);
126
+ const toDelete: string[] = [];
127
+ for (const k of this.files.keys()) {
128
+ if (k.startsWith(prefix)) toDelete.push(k);
129
+ }
130
+ for (const k of toDelete) this.files.delete(k);
131
+ }
132
+ }
@@ -0,0 +1,89 @@
1
+ // node:fs-backed implementation (synchronous). Anchored at a configured
2
+ // `root`. All FileSystem methods take block-relative paths; this wrapper
3
+ // joins them against root before touching disk.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import type { DirEntry, FileSystem } from "./api";
8
+
9
+ export class NodeFileSystem implements FileSystem {
10
+ constructor(private root: string) {}
11
+
12
+ private abs(p: string): string {
13
+ return path.resolve(this.root, p);
14
+ }
15
+
16
+ read(p: string): string {
17
+ return fs.readFileSync(this.abs(p), "utf-8");
18
+ }
19
+
20
+ write(p: string, content: string): void {
21
+ const a = this.abs(p);
22
+ fs.mkdirSync(path.dirname(a), { recursive: true });
23
+ fs.writeFileSync(a, content, "utf-8");
24
+ }
25
+
26
+ writeBinary(p: string, content: Uint8Array): void {
27
+ const a = this.abs(p);
28
+ fs.mkdirSync(path.dirname(a), { recursive: true });
29
+ fs.writeFileSync(a, content);
30
+ }
31
+
32
+ exists(p: string): boolean {
33
+ try {
34
+ fs.statSync(this.abs(p));
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ list(dir: string): string[] {
42
+ const a = this.abs(dir);
43
+ try {
44
+ const stat = fs.statSync(a);
45
+ if (!stat.isDirectory()) return [];
46
+ } catch {
47
+ return [];
48
+ }
49
+ const out: string[] = [];
50
+ function walk(rel: string, absDir: string): void {
51
+ const entries = fs.readdirSync(absDir, { withFileTypes: true });
52
+ for (const e of entries) {
53
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
54
+ const childAbs = path.join(absDir, e.name);
55
+ if (e.isDirectory()) {
56
+ walk(childRel, childAbs);
57
+ } else if (e.isFile()) {
58
+ out.push(childRel);
59
+ }
60
+ }
61
+ }
62
+ walk(dir === "" || dir === "." ? "" : dir, a);
63
+ return out.sort();
64
+ }
65
+
66
+ listDir(dir: string): DirEntry[] {
67
+ const a = this.abs(dir);
68
+ let entries;
69
+ try {
70
+ entries = fs.readdirSync(a, { withFileTypes: true });
71
+ } catch {
72
+ return [];
73
+ }
74
+ return entries
75
+ .map((e) => ({ name: e.name, isDirectory: e.isDirectory() }))
76
+ .sort((x, y) => x.name.localeCompare(y.name));
77
+ }
78
+
79
+ move(from: string, to: string): void {
80
+ const fromA = this.abs(from);
81
+ const toA = this.abs(to);
82
+ fs.mkdirSync(path.dirname(toA), { recursive: true });
83
+ fs.renameSync(fromA, toA);
84
+ }
85
+
86
+ delete(p: string): void {
87
+ fs.rmSync(this.abs(p), { recursive: true, force: true });
88
+ }
89
+ }
@@ -0,0 +1,42 @@
1
+ // Minimal glob matcher for the structurer's file-existence triggers.
2
+ //
3
+ // Supports `**` (any number of path segments, including none), `*` (any
4
+ // run of chars except `/`), and `?` (a single non-`/` char); every other
5
+ // character is matched literally. No brace expansion / char classes — the
6
+ // patterns we match are deliberately simple (e.g. `src/**/*.test.ts`).
7
+ // Paths are matched whole (anchored at both ends).
8
+
9
+ const REGEXP_SPECIALS = /[.+^${}()|[\]\\]/g;
10
+
11
+ /** Compile a glob to an anchored RegExp. */
12
+ export function globToRegExp(glob: string): RegExp {
13
+ let re = "";
14
+ for (let i = 0; i < glob.length; i++) {
15
+ const c = glob[i]!;
16
+ if (c === "*") {
17
+ if (glob[i + 1] === "*") {
18
+ // `**`: any path segments. Absorb a following `/` so `**/x` also
19
+ // matches `x` at depth 0 (zero segments).
20
+ i++;
21
+ if (glob[i + 1] === "/") {
22
+ i++;
23
+ re += "(?:.*/)?";
24
+ } else {
25
+ re += ".*";
26
+ }
27
+ } else {
28
+ re += "[^/]*";
29
+ }
30
+ } else if (c === "?") {
31
+ re += "[^/]";
32
+ } else {
33
+ re += c.replace(REGEXP_SPECIALS, "\\$&");
34
+ }
35
+ }
36
+ return new RegExp(`^${re}$`);
37
+ }
38
+
39
+ /** True if `path` matches `glob` (whole-path, anchored). */
40
+ export function matchesGlob(glob: string, path: string): boolean {
41
+ return globToRegExp(glob).test(path);
42
+ }
@@ -0,0 +1,149 @@
1
+ // Tree IR for the structurer engine.
2
+ //
3
+ // `defineStructure(fn)` invokes module-global builders that append to a
4
+ // tree of group frames (`scope` / `when` / mode frames) and leaf
5
+ // primitives (file / folder actions). `flatten` walks this tree to a
6
+ // flat ordered list of `FlatItem`s — each with concrete scope, bound
7
+ // module path, composed trigger, resolved filesystem path, and the set
8
+ // of run modes the leaf fires in.
9
+
10
+ import type { Scope, RunContext } from "./api";
11
+
12
+ /** The three leaf-firing modes. `check` and `refresh` share the
13
+ * `"default"` mode — they run the same leaves, the `dryRun` flag (carried
14
+ * separately on `RunContext`) only decides whether writes happen.
15
+ * `"updateDeps"` is `--update-deps-only`; `"init"` is the init
16
+ * constructor. A leaf declares which modes it fires in via its enclosing
17
+ * mode frame (see `ModeFrame`); the runner derives the current mode from
18
+ * the run context and keeps only the leaves whose membership includes it. */
19
+ export type RunMode = "default" | "updateDeps" | "init";
20
+
21
+ /** Predicate evaluated against derived trigger context. */
22
+ export type TriggerContext = {
23
+ ctx: RunContext;
24
+ version: number;
25
+ /** Block-relative path queries against the post-structural FS snapshot. */
26
+ pathExists(path: string): boolean;
27
+ pathMissing(path: string): boolean;
28
+ /** True if any file in the snapshot matches `glob`, resolved
29
+ * MODULE-RELATIVE to the leaf's bound module: a co-located-test glob
30
+ * inside `scope("model")` is checked under the `model/` dir. At the
31
+ * block root the glob is block-relative. Unlike `pathExists`/`pathMissing`
32
+ * (block-relative), `filesMatch` is the module-aware predicate used by
33
+ * `whenFilesExist`. */
34
+ filesMatch(glob: string): boolean;
35
+ };
36
+
37
+ export type TriggerFn = (tctx: TriggerContext) => boolean;
38
+
39
+ /** Content forms — what to write into a file when creating it.
40
+ * `tpl.vars` may be a record or a thunk returning a record; the latter
41
+ * lets rule authors call `blockVars()` lazily at resolve time. */
42
+ export type TplVarsLike = Record<string, string> | (() => Record<string, string>);
43
+ export type ContentForm =
44
+ | { kind: "file"; path: string }
45
+ | { kind: "text"; value: string }
46
+ | { kind: "tpl"; path: string; vars: TplVarsLike }
47
+ | { kind: "generate"; fn: () => unknown }
48
+ // A binary static asset (e.g. a logo PNG) copied verbatim from the
49
+ // template tree. Carried as raw bytes, never decoded to a string —
50
+ // only `seed` / `scaffold` consume it (via fs.writeBinary).
51
+ | { kind: "binary"; path: string };
52
+
53
+ /** Builder-body for a `managed` file (the content-rules lambda). */
54
+ export type ManagedBody = () => void;
55
+
56
+ // --- Leaf primitives ---
57
+
58
+ export type FixedItem = {
59
+ kind: "fixed";
60
+ path: string;
61
+ content: ContentForm;
62
+ };
63
+
64
+ export type ManagedItem = {
65
+ kind: "managed";
66
+ path: string;
67
+ initial: ContentForm;
68
+ body: ManagedBody;
69
+ };
70
+
71
+ export type ScaffoldItem = {
72
+ kind: "scaffold";
73
+ path: string;
74
+ initial: ContentForm;
75
+ };
76
+
77
+ export type SeedItem = {
78
+ kind: "seed";
79
+ path: string;
80
+ initial: ContentForm;
81
+ };
82
+
83
+ export type RemoveItem = {
84
+ kind: "remove";
85
+ path: string;
86
+ };
87
+
88
+ export type RenameItem = {
89
+ kind: "rename";
90
+ from: string;
91
+ to: string;
92
+ };
93
+
94
+ export type LeafItem = FixedItem | ManagedItem | ScaffoldItem | SeedItem | RemoveItem | RenameItem;
95
+
96
+ // --- Group frames ---
97
+
98
+ export type ScopeFrame = {
99
+ kind: "scope";
100
+ scope: Scope;
101
+ children: TreeNode[];
102
+ };
103
+
104
+ export type WhenFrame = {
105
+ kind: "when";
106
+ trigger: TriggerFn;
107
+ children: TreeNode[];
108
+ };
109
+
110
+ /** Top-level mode frame: contained leaves fire only in the listed run
111
+ * modes. Leaves outside any mode frame fire in `["default", "init"]`
112
+ * (refresh/check + init). `onUpdateDeps()` produces `["updateDeps"]`;
113
+ * `onInitOrUpdate()` produces `["init", "updateDeps"]`. */
114
+ export type ModeFrame = {
115
+ kind: "mode";
116
+ modes: RunMode[];
117
+ children: TreeNode[];
118
+ };
119
+
120
+ export type TreeNode = LeafItem | ScopeFrame | WhenFrame | ModeFrame;
121
+
122
+ /** Structure object returned by `defineStructure`. */
123
+ export type Structure = {
124
+ /** Top-level children — may contain leaves, scope frames, when
125
+ * frames, and mode frames. */
126
+ children: TreeNode[];
127
+ };
128
+
129
+ // --- Post-flatten ---
130
+
131
+ /**
132
+ * After fan-out: one entry per (leaf × matching module). `scope` is
133
+ * always set; `modulePath` is the workspace path the leaf is bound to
134
+ * (empty string for the workspace root). `triggers` is the AND of all
135
+ * enclosing `when` predicates. `modes` is the set of run modes the leaf
136
+ * fires in — `["default", "init"]` outside any mode frame, otherwise the
137
+ * enclosing mode frame's `modes`.
138
+ */
139
+ export type FlatItem = {
140
+ leaf: LeafItem;
141
+ scope: Scope;
142
+ modulePath: string;
143
+ /** Block-relative resolved path(s). */
144
+ resolvedPath: string;
145
+ /** For rename, the resolved destination. */
146
+ resolvedTo?: string;
147
+ triggers: TriggerFn[];
148
+ modes: RunMode[];
149
+ };
@@ -0,0 +1,212 @@
1
+ // JSON parser + serialiser + jsonPath helpers + top-level key reorder.
2
+ //
3
+ // `parseJson`/`stringifyJson` are the canonical round-trip primitives the
4
+ // managed pass uses. The dot-notation helpers (`getAtPath`, `setAtPath`,
5
+ // `hasAtPath`, `deleteAtPath`) back the content-rule builders. `reorderTopLevel`
6
+ // implements the "known keys in declared order, unknown keys stay adjacent to
7
+ // preceding known key" projection.
8
+
9
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
10
+
11
+ export type JsonObject = { [k: string]: unknown };
12
+
13
+ /** Parse JSON; throws on invalid input. */
14
+ export function parseJson(raw: string): unknown {
15
+ return JSON.parse(raw);
16
+ }
17
+
18
+ // oxfmt formats JSON two ways, keyed on filename:
19
+ // - package.json: every array/object fully expanded, one element per line,
20
+ // regardless of width (what `JSON.stringify(…, 2)` already produces).
21
+ // - any other .json (e.g. tsconfig.json): oxfmt's generic shape —
22
+ // * objects: always expanded, one property per line (oxfmt preserves
23
+ // the brace newline, so the expanded form is a fixpoint);
24
+ // * arrays: collapsed onto one line when that line — its indent, the
25
+ // `"key": ` prefix, the flat array, and a trailing comma if a sibling
26
+ // follows — fits `PRINT_WIDTH` (oxfmt's default 100), else broken one
27
+ // element per line.
28
+ // This is exactly the form `oxfmt` itself produces, verified against
29
+ // oxfmt 0.35 at the array boundary (≤100 collapses, 101 breaks; a
30
+ // trailing comma counts) and across all in-mono blocks. The single-
31
+ // element `include` array was the regression: `JSON.stringify` expanded
32
+ // it while oxfmt collapses it.
33
+ // `stringifyJson` defaults to the expanded form; callers serialising a
34
+ // generic .json pass `{ collapse: true }` so the output is oxfmt-clean by
35
+ // construction (no prior `pnpm fmt` needed for `check` — the same
36
+ // emit-clean-by-design contract as the canonical key/dependency ordering).
37
+ const PRINT_WIDTH = 100;
38
+ const INDENT = 2;
39
+
40
+ /** One-line rendering of an array, matching oxfmt's flat spacing: `[a, b]`
41
+ * (no inner padding), nested objects inline as `{ "k": v }`, empty `[]`.
42
+ * Only arrays collapse, so this is the array-fits primitive. */
43
+ function flatArray(value: unknown[]): string {
44
+ if (value.length === 0) return "[]";
45
+ return "[" + value.map(flatValue).join(", ") + "]";
46
+ }
47
+
48
+ /** One-line rendering of any value (used inside a collapsed array, where
49
+ * contained objects render inline). */
50
+ function flatValue(value: unknown): string {
51
+ if (Array.isArray(value)) return flatArray(value);
52
+ if (isObject(value)) {
53
+ const entries = Object.entries(value);
54
+ if (entries.length === 0) return "{}";
55
+ return (
56
+ "{ " + entries.map(([k, v]) => `${JSON.stringify(k)}: ${flatValue(v)}`).join(", ") + " }"
57
+ );
58
+ }
59
+ return JSON.stringify(value);
60
+ }
61
+
62
+ /** Print `value` starting at `column`. Objects always break (one property per
63
+ * line); arrays collapse when their flat line — `column` + the flat array +
64
+ * `reserved` (1 for a trailing comma when a sibling follows) — fits
65
+ * `PRINT_WIDTH`, else break. `indent` is the leading-space width of the line
66
+ * the container opens on. */
67
+ function printValue(value: unknown, indent: number, column: number, reserved: number): string {
68
+ const inner = indent + INDENT;
69
+ const pad = " ".repeat(inner);
70
+ if (Array.isArray(value)) {
71
+ const flat = flatArray(value);
72
+ if (column + flat.length + reserved <= PRINT_WIDTH) return flat;
73
+ const last = value.length - 1;
74
+ const lines = value.map((item, i) => pad + printValue(item, inner, inner, i === last ? 0 : 1));
75
+ return "[\n" + lines.join(",\n") + "\n" + " ".repeat(indent) + "]";
76
+ }
77
+ if (isObject(value)) {
78
+ const entries = Object.entries(value);
79
+ if (entries.length === 0) return "{}";
80
+ const last = entries.length - 1;
81
+ const lines = entries.map(([k, v], i) => {
82
+ const prefix = `${pad}${JSON.stringify(k)}: `;
83
+ return prefix + printValue(v, inner, prefix.length, i === last ? 0 : 1);
84
+ });
85
+ return "{\n" + lines.join(",\n") + "\n" + " ".repeat(indent) + "}";
86
+ }
87
+ return JSON.stringify(value);
88
+ }
89
+
90
+ /** Canonical serialiser: 2-space indent, trailing newline.
91
+ * Default — every container expanded (the package.json shape, byte-identical
92
+ * to `JSON.stringify(…, 2)`).
93
+ * `{ collapse: true }` — oxfmt's generic-.json shape: containers that fit
94
+ * `PRINT_WIDTH` collapse onto one line. Used for tsconfig.json and any other
95
+ * non-package.json the engine emits, so `oxfmt --check` passes without a
96
+ * prior format pass. */
97
+ export function stringifyJson(value: unknown, opts?: { collapse?: boolean }): string {
98
+ if (!opts?.collapse) return JSON.stringify(value, null, 2) + "\n";
99
+ // Normalise through a round-trip so non-JSON inputs (undefined values,
100
+ // functions) are dropped exactly as `JSON.stringify` would.
101
+ const normalized = JSON.parse(JSON.stringify(value ?? null));
102
+ return printValue(normalized, 0, 0, 0) + "\n";
103
+ }
104
+
105
+ function splitPath(jsonPath: string): string[] {
106
+ if (jsonPath === "" || jsonPath === undefined) return [];
107
+ return jsonPath.split(".");
108
+ }
109
+
110
+ function isObject(v: unknown): v is JsonObject {
111
+ return typeof v === "object" && v !== null && !Array.isArray(v);
112
+ }
113
+
114
+ /** Get the value at `jsonPath`. Returns `undefined` if any segment is
115
+ * missing or not an object. */
116
+ export function getAtPath(obj: JsonObject, jsonPath: string): unknown {
117
+ const parts = splitPath(jsonPath);
118
+ let cur: unknown = obj;
119
+ for (const p of parts) {
120
+ if (!isObject(cur)) return undefined;
121
+ cur = cur[p];
122
+ }
123
+ return cur;
124
+ }
125
+
126
+ /** True if `jsonPath` resolves to a defined value (including `null`). */
127
+ export function hasAtPath(obj: JsonObject, jsonPath: string): boolean {
128
+ const parts = splitPath(jsonPath);
129
+ if (parts.length === 0) return true;
130
+ let cur: unknown = obj;
131
+ for (let i = 0; i < parts.length - 1; i++) {
132
+ if (!isObject(cur)) return false;
133
+ cur = cur[parts[i]!];
134
+ }
135
+ if (!isObject(cur)) return false;
136
+ return parts[parts.length - 1]! in cur;
137
+ }
138
+
139
+ /** Set `value` at `jsonPath`. Auto-creates intermediate objects. Throws
140
+ * if a non-object value is on the path (would be silently overwritten
141
+ * otherwise — caller bug). */
142
+ export function setAtPath(obj: JsonObject, jsonPath: string, value: unknown): void {
143
+ const parts = splitPath(jsonPath);
144
+ if (parts.length === 0) {
145
+ throw new Error("setAtPath: jsonPath must be non-empty");
146
+ }
147
+ let cur: JsonObject = obj;
148
+ for (let i = 0; i < parts.length - 1; i++) {
149
+ const k = parts[i]!;
150
+ const next = cur[k];
151
+ if (next === undefined) {
152
+ const fresh: JsonObject = {};
153
+ cur[k] = fresh;
154
+ cur = fresh;
155
+ } else if (isObject(next)) {
156
+ cur = next;
157
+ } else {
158
+ throw new Error(
159
+ `setAtPath: cannot descend into non-object at '${parts.slice(0, i + 1).join(".")}'`,
160
+ );
161
+ }
162
+ }
163
+ cur[parts[parts.length - 1]!] = value;
164
+ }
165
+
166
+ /** Delete the value at `jsonPath`. No-op if any segment is missing. */
167
+ export function deleteAtPath(obj: JsonObject, jsonPath: string): void {
168
+ const parts = splitPath(jsonPath);
169
+ if (parts.length === 0) {
170
+ throw new Error("deleteAtPath: jsonPath must be non-empty");
171
+ }
172
+ let cur: unknown = obj;
173
+ for (let i = 0; i < parts.length - 1; i++) {
174
+ if (!isObject(cur)) return;
175
+ cur = cur[parts[i]!];
176
+ }
177
+ if (!isObject(cur)) return;
178
+ delete cur[parts[parts.length - 1]!];
179
+ }
180
+
181
+ /**
182
+ * Reorder top-level keys of `obj`:
183
+ * - Known keys (those listed in `order`) appear in declared order.
184
+ * - Unknown keys (not in `order`) stay adjacent to the preceding known
185
+ * key they followed in the source. Unknown keys appearing before any
186
+ * known key go first, in source order.
187
+ */
188
+ export function reorderTopLevel(obj: JsonObject, order: readonly string[]): JsonObject {
189
+ const known = new Set(order);
190
+ const sourceKeys = Object.keys(obj);
191
+
192
+ const adjacents = new Map<string, string[]>();
193
+ adjacents.set("", []);
194
+ let anchor = "";
195
+ for (const k of sourceKeys) {
196
+ if (known.has(k)) {
197
+ anchor = k;
198
+ adjacents.set(anchor, []);
199
+ } else {
200
+ adjacents.get(anchor)!.push(k);
201
+ }
202
+ }
203
+
204
+ const out: JsonObject = {};
205
+ for (const k of adjacents.get("")!) out[k] = obj[k];
206
+ for (const k of order) {
207
+ if (!(k in obj)) continue;
208
+ out[k] = obj[k];
209
+ for (const u of adjacents.get(k) ?? []) out[u] = obj[u];
210
+ }
211
+ return out;
212
+ }
@@ -0,0 +1,42 @@
1
+ // .gitignore-style line list parser/serialiser.
2
+ //
3
+ // `parseLines` keeps the file verbatim as an array of lines (one per
4
+ // "\n"). `serializeLines` joins them with "\n" and guarantees a trailing
5
+ // newline. `normaliseLine` strips comments and surrounding whitespace —
6
+ // used by the `ensureGitignoreEntries` family to compare an existing
7
+ // line to a required entry without being fooled by inline `#` comments
8
+ // or extra indentation.
9
+
10
+ export function parseLines(raw: string): string[] {
11
+ // Drop the synthetic empty entry caused by a trailing newline so a
12
+ // serialise-then-parse round-trip is stable.
13
+ const lines = raw.split("\n");
14
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
15
+ return lines;
16
+ }
17
+
18
+ export function serializeLines(lines: readonly string[]): string {
19
+ return lines.join("\n") + "\n";
20
+ }
21
+
22
+ /** Strip leading/trailing whitespace and trailing inline `# ...` comments.
23
+ * Returns empty string for blank / comment-only lines. */
24
+ export function normaliseLine(line: string): string {
25
+ const noComment = line.replace(/(^|\s)#.*$/, "$1");
26
+ return noComment.trim();
27
+ }
28
+
29
+ /** True if `line` (after normalisation) equals `entry` (after trim). */
30
+ export function lineEqualsEntry(line: string, entry: string): boolean {
31
+ const n = normaliseLine(line);
32
+ if (n === "") return false;
33
+ return n === entry.trim();
34
+ }
35
+
36
+ /** True if any line in `lines` normalises to `entry`. */
37
+ export function containsEntry(lines: readonly string[], entry: string): boolean {
38
+ for (const line of lines) {
39
+ if (lineEqualsEntry(line, entry)) return true;
40
+ }
41
+ return false;
42
+ }