@metaobjectsdev/sdk 0.5.0-rc.1

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 (118) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +32 -0
  3. package/dist/agent-docs/body.d.ts +2 -0
  4. package/dist/agent-docs/body.d.ts.map +1 -0
  5. package/dist/agent-docs/body.js +563 -0
  6. package/dist/agent-docs/body.js.map +1 -0
  7. package/dist/agent-docs/content-hash.d.ts +8 -0
  8. package/dist/agent-docs/content-hash.d.ts.map +1 -0
  9. package/dist/agent-docs/content-hash.js +23 -0
  10. package/dist/agent-docs/content-hash.js.map +1 -0
  11. package/dist/agent-docs/index.d.ts +3 -0
  12. package/dist/agent-docs/index.d.ts.map +1 -0
  13. package/dist/agent-docs/index.js +4 -0
  14. package/dist/agent-docs/index.js.map +1 -0
  15. package/dist/config.d.ts +113 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +53 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/forge-types.d.ts +47 -0
  20. package/dist/forge-types.d.ts.map +1 -0
  21. package/dist/forge-types.js +133 -0
  22. package/dist/forge-types.js.map +1 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +39 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/memory.d.ts +30 -0
  28. package/dist/memory.d.ts.map +1 -0
  29. package/dist/memory.js +105 -0
  30. package/dist/memory.js.map +1 -0
  31. package/dist/package.d.ts +65 -0
  32. package/dist/package.d.ts.map +1 -0
  33. package/dist/package.js +105 -0
  34. package/dist/package.js.map +1 -0
  35. package/dist/paths.d.ts +5 -0
  36. package/dist/paths.d.ts.map +1 -0
  37. package/dist/paths.js +26 -0
  38. package/dist/paths.js.map +1 -0
  39. package/dist/records/any.d.ts +467 -0
  40. package/dist/records/any.d.ts.map +1 -0
  41. package/dist/records/any.js +14 -0
  42. package/dist/records/any.js.map +1 -0
  43. package/dist/records/convention.d.ts +90 -0
  44. package/dist/records/convention.d.ts.map +1 -0
  45. package/dist/records/convention.js +9 -0
  46. package/dist/records/convention.js.map +1 -0
  47. package/dist/records/core.d.ts +84 -0
  48. package/dist/records/core.d.ts.map +1 -0
  49. package/dist/records/core.js +47 -0
  50. package/dist/records/core.js.map +1 -0
  51. package/dist/records/decision.d.ts +90 -0
  52. package/dist/records/decision.d.ts.map +1 -0
  53. package/dist/records/decision.js +9 -0
  54. package/dist/records/decision.js.map +1 -0
  55. package/dist/records/failure.d.ts +93 -0
  56. package/dist/records/failure.d.ts.map +1 -0
  57. package/dist/records/failure.js +10 -0
  58. package/dist/records/failure.js.map +1 -0
  59. package/dist/records/glossary.d.ts +111 -0
  60. package/dist/records/glossary.d.ts.map +1 -0
  61. package/dist/records/glossary.js +14 -0
  62. package/dist/records/glossary.js.map +1 -0
  63. package/dist/records/principle.d.ts +99 -0
  64. package/dist/records/principle.d.ts.map +1 -0
  65. package/dist/records/principle.js +12 -0
  66. package/dist/records/principle.js.map +1 -0
  67. package/dist/storage/errors.d.ts +14 -0
  68. package/dist/storage/errors.d.ts.map +1 -0
  69. package/dist/storage/errors.js +27 -0
  70. package/dist/storage/errors.js.map +1 -0
  71. package/dist/storage/index.d.ts +7 -0
  72. package/dist/storage/index.d.ts.map +1 -0
  73. package/dist/storage/index.js +6 -0
  74. package/dist/storage/index.js.map +1 -0
  75. package/dist/storage/lifecycle.d.ts +5 -0
  76. package/dist/storage/lifecycle.d.ts.map +1 -0
  77. package/dist/storage/lifecycle.js +27 -0
  78. package/dist/storage/lifecycle.js.map +1 -0
  79. package/dist/storage/list.d.ts +8 -0
  80. package/dist/storage/list.d.ts.map +1 -0
  81. package/dist/storage/list.js +42 -0
  82. package/dist/storage/list.js.map +1 -0
  83. package/dist/storage/read.d.ts +9 -0
  84. package/dist/storage/read.d.ts.map +1 -0
  85. package/dist/storage/read.js +43 -0
  86. package/dist/storage/read.js.map +1 -0
  87. package/dist/storage/write.d.ts +8 -0
  88. package/dist/storage/write.d.ts.map +1 -0
  89. package/dist/storage/write.js +20 -0
  90. package/dist/storage/write.js.map +1 -0
  91. package/dist/workspace.d.ts +49 -0
  92. package/dist/workspace.d.ts.map +1 -0
  93. package/dist/workspace.js +280 -0
  94. package/dist/workspace.js.map +1 -0
  95. package/package.json +48 -0
  96. package/src/agent-docs/body.ts +562 -0
  97. package/src/agent-docs/content-hash.ts +25 -0
  98. package/src/agent-docs/index.ts +8 -0
  99. package/src/config.ts +69 -0
  100. package/src/forge-types.ts +167 -0
  101. package/src/index.ts +98 -0
  102. package/src/memory.ts +116 -0
  103. package/src/package.ts +120 -0
  104. package/src/paths.ts +30 -0
  105. package/src/records/any.ts +15 -0
  106. package/src/records/convention.ts +10 -0
  107. package/src/records/core.ts +55 -0
  108. package/src/records/decision.ts +10 -0
  109. package/src/records/failure.ts +11 -0
  110. package/src/records/glossary.ts +15 -0
  111. package/src/records/principle.ts +13 -0
  112. package/src/storage/errors.ts +23 -0
  113. package/src/storage/index.ts +10 -0
  114. package/src/storage/lifecycle.ts +38 -0
  115. package/src/storage/list.ts +53 -0
  116. package/src/storage/read.ts +54 -0
  117. package/src/storage/write.ts +32 -0
  118. package/src/workspace.ts +342 -0
@@ -0,0 +1,23 @@
1
+ export class MetaForgeRecordNotFoundError extends Error {
2
+ constructor(public readonly path: string) {
3
+ super(`Record not found: ${path}`);
4
+ this.name = "MetaForgeRecordNotFoundError";
5
+ }
6
+ }
7
+
8
+ export class MetaForgeAlreadyPromotedError extends Error {
9
+ constructor(public readonly path: string) {
10
+ super(`A canonical record already exists at: ${path}`);
11
+ this.name = "MetaForgeAlreadyPromotedError";
12
+ }
13
+ }
14
+
15
+ export class MetaForgeRecordParseError extends Error {
16
+ constructor(
17
+ public readonly path: string,
18
+ public override readonly cause: unknown,
19
+ ) {
20
+ super(`Failed to parse record at ${path}: ${cause instanceof Error ? cause.message : String(cause)}`);
21
+ this.name = "MetaForgeRecordParseError";
22
+ }
23
+ }
@@ -0,0 +1,10 @@
1
+ export { readRecord, recordExists } from "./read.js";
2
+ export { writeRecord, removeRecord } from "./write.js";
3
+ export { listRecords } from "./list.js";
4
+ export type { ListOptions } from "./list.js";
5
+ export { promoteRecord, supersede } from "./lifecycle.js";
6
+ export {
7
+ MetaForgeRecordNotFoundError,
8
+ MetaForgeAlreadyPromotedError,
9
+ MetaForgeRecordParseError,
10
+ } from "./errors.js";
@@ -0,0 +1,38 @@
1
+ import type { AnyRecord } from "../records/any.js";
2
+ import type { RecordType } from "../records/core.js";
3
+ import { readRecord, recordExists } from "./read.js";
4
+ import { writeRecord, removeRecord } from "./write.js";
5
+ import { MetaForgeAlreadyPromotedError, MetaForgeRecordNotFoundError } from "./errors.js";
6
+ import { recordPath } from "../paths.js";
7
+
8
+ export async function promoteRecord(
9
+ metaRoot: string,
10
+ type: RecordType,
11
+ id: string,
12
+ ): Promise<void> {
13
+ if (!(await recordExists(metaRoot, type, id, { pending: true }))) {
14
+ throw new MetaForgeRecordNotFoundError(recordPath(metaRoot, type, id, { pending: true }));
15
+ }
16
+ if (await recordExists(metaRoot, type, id)) {
17
+ throw new MetaForgeAlreadyPromotedError(recordPath(metaRoot, type, id));
18
+ }
19
+ const record = await readRecord(metaRoot, type, id, { pending: true });
20
+ await writeRecord(metaRoot, record);
21
+ await removeRecord(metaRoot, type, id, { pending: true });
22
+ }
23
+
24
+ export async function supersede(
25
+ metaRoot: string,
26
+ oldId: string,
27
+ newRecord: AnyRecord,
28
+ ): Promise<void> {
29
+ if (!(await recordExists(metaRoot, newRecord.type, oldId))) {
30
+ throw new MetaForgeRecordNotFoundError(recordPath(metaRoot, newRecord.type, oldId));
31
+ }
32
+ // Write the new record first, so a failure leaves the old record intact.
33
+ await writeRecord(metaRoot, newRecord);
34
+ // Then update the old record to mark it superseded.
35
+ const old = await readRecord(metaRoot, newRecord.type, oldId);
36
+ const updated = { ...old, superseded_by: newRecord.id };
37
+ await writeRecord(metaRoot, updated);
38
+ }
@@ -0,0 +1,53 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, basename } from "node:path";
3
+ import type { AnyRecord } from "../records/any.js";
4
+ import type { RecordType } from "../records/core.js";
5
+ import { recordPath } from "../paths.js";
6
+ import { readRecord } from "./read.js";
7
+
8
+ export interface ListOptions {
9
+ pending?: boolean;
10
+ onInvalid?: (path: string, err: unknown) => void;
11
+ }
12
+
13
+ export async function listRecords(
14
+ metaRoot: string,
15
+ type: RecordType,
16
+ opts: ListOptions = {},
17
+ ): Promise<AnyRecord[]> {
18
+ const dir = directoryFor(metaRoot, type, opts.pending);
19
+ let entries: string[];
20
+ try {
21
+ entries = await readdir(dir);
22
+ } catch (err) {
23
+ if (isNoEntError(err)) return [];
24
+ throw err;
25
+ }
26
+ const results: AnyRecord[] = [];
27
+ for (const entry of entries) {
28
+ if (!entry.endsWith(".json")) continue;
29
+ const id = basename(entry, ".json");
30
+ try {
31
+ const rec = await readRecord(metaRoot, type, id, opts.pending ? { pending: true } : {});
32
+ results.push(rec);
33
+ } catch (err) {
34
+ opts.onInvalid?.(join(dir, entry), err);
35
+ }
36
+ }
37
+ return results;
38
+ }
39
+
40
+ function directoryFor(metaRoot: string, type: RecordType, pending?: boolean): string {
41
+ // Re-derive from a sentinel id so we use a single source of truth for layout.
42
+ const sentinelPath = recordPath(metaRoot, type, "__sentinel__", pending ? { pending: true } : {});
43
+ return sentinelPath.slice(0, sentinelPath.length - "__sentinel__.json".length - 1);
44
+ }
45
+
46
+ function isNoEntError(err: unknown): boolean {
47
+ return (
48
+ typeof err === "object" &&
49
+ err !== null &&
50
+ "code" in err &&
51
+ (err as { code: unknown }).code === "ENOENT"
52
+ );
53
+ }
@@ -0,0 +1,54 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import type { AnyRecord } from "../records/any.js";
3
+ import { AnyRecord as AnyRecordSchema } from "../records/any.js";
4
+ import type { RecordType } from "../records/core.js";
5
+ import { recordPath } from "../paths.js";
6
+ import { MetaForgeRecordNotFoundError, MetaForgeRecordParseError } from "./errors.js";
7
+
8
+ export async function readRecord(
9
+ metaRoot: string,
10
+ type: RecordType,
11
+ id: string,
12
+ opts: { pending?: boolean } = {},
13
+ ): Promise<AnyRecord> {
14
+ const path = recordPath(metaRoot, type, id, opts);
15
+ let raw: string;
16
+ try {
17
+ raw = await readFile(path, "utf8");
18
+ } catch (err) {
19
+ if (isNoEntError(err)) throw new MetaForgeRecordNotFoundError(path);
20
+ throw err;
21
+ }
22
+ let parsedJson: unknown;
23
+ try {
24
+ parsedJson = JSON.parse(raw);
25
+ } catch (err) {
26
+ throw new MetaForgeRecordParseError(path, err);
27
+ }
28
+ const result = AnyRecordSchema.safeParse(parsedJson);
29
+ if (!result.success) throw new MetaForgeRecordParseError(path, result.error);
30
+ return result.data;
31
+ }
32
+
33
+ export async function recordExists(
34
+ metaRoot: string,
35
+ type: RecordType,
36
+ id: string,
37
+ opts: { pending?: boolean } = {},
38
+ ): Promise<boolean> {
39
+ try {
40
+ await stat(recordPath(metaRoot, type, id, opts));
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ function isNoEntError(err: unknown): boolean {
48
+ return (
49
+ typeof err === "object" &&
50
+ err !== null &&
51
+ "code" in err &&
52
+ (err as { code: unknown }).code === "ENOENT"
53
+ );
54
+ }
@@ -0,0 +1,32 @@
1
+ import { mkdir, writeFile, unlink } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { AnyRecord } from "../records/any.js";
4
+ import { AnyRecord as AnyRecordSchema } from "../records/any.js";
5
+ import { recordPath } from "../paths.js";
6
+ import { MetaForgeRecordNotFoundError } from "./errors.js";
7
+ import { recordExists } from "./read.js";
8
+
9
+ export async function writeRecord(
10
+ metaRoot: string,
11
+ record: AnyRecord,
12
+ opts: { pending?: boolean } = {},
13
+ ): Promise<void> {
14
+ // Validate before any IO
15
+ AnyRecordSchema.parse(record);
16
+
17
+ const path = recordPath(metaRoot, record.type, record.id, opts);
18
+ await mkdir(dirname(path), { recursive: true });
19
+ await writeFile(path, JSON.stringify(record, null, 2) + "\n", "utf8");
20
+ }
21
+
22
+ export async function removeRecord(
23
+ metaRoot: string,
24
+ type: AnyRecord["type"],
25
+ id: string,
26
+ opts: { pending?: boolean } = {},
27
+ ): Promise<void> {
28
+ if (!(await recordExists(metaRoot, type, id, opts))) {
29
+ throw new MetaForgeRecordNotFoundError(recordPath(metaRoot, type, id, opts));
30
+ }
31
+ await unlink(recordPath(metaRoot, type, id, opts));
32
+ }
@@ -0,0 +1,342 @@
1
+ // Workspace discovery — finds peer metadata packages in a monorepo via the
2
+ // workspace conventions used by pnpm/npm/bun. The result is a registry of
3
+ // `package.meta.json` files keyed by their `name` and `metaobjectsPackage`,
4
+ // used by loadMemory (when extends: is present) and by future cross-package
5
+ // codegen/migrate flows.
6
+ //
7
+ // Discovery sources (first match wins, walking up from cwd):
8
+ // - pnpm-workspace.yaml (packages: array)
9
+ // - package.json workspaces (array of globs OR { packages: [...] })
10
+ //
11
+ // This is the prototype implementation. Full v0.3 SP2 will harden:
12
+ // - Bun workspaces (currently mirrors package.json field)
13
+ // - Cargo / Maven workspaces (future polyglot support)
14
+ // - Lockfile-style resolution + version range matching
15
+ // - Better error messages on circular extends, missing packages
16
+
17
+ import { readFile, readdir, stat } from "node:fs/promises";
18
+ import { dirname, join, resolve, basename } from "node:path";
19
+ import {
20
+ type PackageManifest,
21
+ PackageManifestSchema,
22
+ PACKAGE_MANIFEST_FILE,
23
+ resolveMetaobjectsPackage,
24
+ } from "./package.js";
25
+
26
+ const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
27
+ const PACKAGE_JSON = "package.json";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Public types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface WorkspacePackage {
34
+ /** Absolute path to the .meta/ directory. */
35
+ metaDir: string;
36
+ /** The parsed package.meta.json. */
37
+ manifest: PackageManifest;
38
+ /** The canonical metaobjects ref (resolved via resolveMetaobjectsPackage). */
39
+ metaobjectsPackage: string;
40
+ }
41
+
42
+ export interface Workspace {
43
+ /** Absolute path to the workspace root (where pnpm-workspace.yaml or package.json lives). */
44
+ root: string;
45
+ /** All metadata packages found under workspace globs. */
46
+ packages: WorkspacePackage[];
47
+ /** Look up by package.meta.json `name` (e.g., "@acme/shared"). */
48
+ findByName(name: string): WorkspacePackage | undefined;
49
+ /** Look up by canonical metaobjects ref (e.g., "acme::shared"). */
50
+ findByMetaobjectsPackage(ref: string): WorkspacePackage | undefined;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Workspace root discovery
55
+ // ---------------------------------------------------------------------------
56
+
57
+ interface WorkspaceConfig {
58
+ root: string;
59
+ /** Workspace globs (e.g., "packages/*"). */
60
+ globs: string[];
61
+ }
62
+
63
+ async function readPnpmWorkspaceFile(path: string): Promise<string[] | undefined> {
64
+ try {
65
+ const content = await readFile(path, "utf8");
66
+ return extractPnpmPackages(content);
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Extract the `packages:` glob list from pnpm-workspace.yaml. Tiny parser —
74
+ * sufficient for the simple-list format pnpm actually emits. Not a general
75
+ * YAML parser; doesn't support quoted strings with special chars, flow
76
+ * mappings, anchors, etc.
77
+ */
78
+ export function extractPnpmPackages(yaml: string): string[] {
79
+ const lines = yaml.split(/\r?\n/);
80
+ const result: string[] = [];
81
+ let inPackages = false;
82
+ for (const rawLine of lines) {
83
+ const line = rawLine.replace(/#.*$/, "").trimEnd();
84
+ if (line.length === 0) continue;
85
+ if (/^packages\s*:/.test(line)) {
86
+ inPackages = true;
87
+ continue;
88
+ }
89
+ if (inPackages) {
90
+ const match = /^\s+-\s+['"]?([^'"]+)['"]?\s*$/.exec(line);
91
+ if (match !== null) {
92
+ result.push(match[1]!);
93
+ continue;
94
+ }
95
+ // Non-list line at the same or lower indent ends the packages: block
96
+ if (/^[A-Za-z_]/.test(line)) {
97
+ inPackages = false;
98
+ }
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+
104
+ async function readPackageJsonWorkspaces(path: string): Promise<string[] | undefined> {
105
+ try {
106
+ const content = await readFile(path, "utf8");
107
+ const parsed = JSON.parse(content) as {
108
+ workspaces?: string[] | { packages?: string[] };
109
+ };
110
+ if (Array.isArray(parsed.workspaces)) return parsed.workspaces;
111
+ if (parsed.workspaces && Array.isArray(parsed.workspaces.packages)) {
112
+ return parsed.workspaces.packages;
113
+ }
114
+ return undefined;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ /** Walk up from `start` looking for the first directory with a workspace config. */
121
+ async function findWorkspaceConfig(start: string): Promise<WorkspaceConfig | undefined> {
122
+ let dir = resolve(start);
123
+ // Cap walk-up at filesystem root; in practice we'll find or stop in a few steps.
124
+ while (true) {
125
+ const pnpmPath = join(dir, PNPM_WORKSPACE_FILE);
126
+ const pnpmGlobs = await readPnpmWorkspaceFile(pnpmPath);
127
+ if (pnpmGlobs !== undefined && pnpmGlobs.length > 0) {
128
+ return { root: dir, globs: pnpmGlobs };
129
+ }
130
+
131
+ const pkgPath = join(dir, PACKAGE_JSON);
132
+ const pkgGlobs = await readPackageJsonWorkspaces(pkgPath);
133
+ if (pkgGlobs !== undefined && pkgGlobs.length > 0) {
134
+ return { root: dir, globs: pkgGlobs };
135
+ }
136
+
137
+ const parent = dirname(dir);
138
+ if (parent === dir) return undefined; // hit filesystem root
139
+ dir = parent;
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Glob expansion (simple, matches workspace-style patterns)
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /** Expand a workspace glob like "packages/*" relative to root. Returns directory paths. */
148
+ async function expandGlob(root: string, glob: string): Promise<string[]> {
149
+ // Strip trailing `/` for normalization
150
+ const pattern = glob.replace(/\/$/, "");
151
+
152
+ // No wildcard: literal directory
153
+ if (!pattern.includes("*")) {
154
+ const literal = join(root, pattern);
155
+ try {
156
+ const s = await stat(literal);
157
+ return s.isDirectory() ? [literal] : [];
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ // Split into prefix segments (literal) + last segment with wildcard.
164
+ // Supports the common cases: "packages/*", "apps/*", "packages/foo-*".
165
+ // Doesn't support deep `**` globs in this prototype.
166
+ const segments = pattern.split("/");
167
+ const lastSegment = segments.pop()!;
168
+ const prefixDir = join(root, ...segments);
169
+
170
+ let entries: string[];
171
+ try {
172
+ entries = await readdir(prefixDir);
173
+ } catch {
174
+ return [];
175
+ }
176
+
177
+ const matcher = matchGlobSegment(lastSegment);
178
+ const result: string[] = [];
179
+ for (const entry of entries) {
180
+ if (!matcher(entry)) continue;
181
+ const full = join(prefixDir, entry);
182
+ try {
183
+ const s = await stat(full);
184
+ if (s.isDirectory()) result.push(full);
185
+ } catch {
186
+ // skip unreadable
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+
192
+ function matchGlobSegment(pattern: string): (value: string) => boolean {
193
+ if (pattern === "*") return () => true;
194
+ const regex = new RegExp(
195
+ "^" +
196
+ pattern
197
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
198
+ .replace(/\*/g, "[^/]*") +
199
+ "$",
200
+ );
201
+ return (value) => regex.test(value);
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Public: discoverWorkspace
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Discover the metaobjects workspace containing `cwd`. Returns undefined if
210
+ * no workspace config (pnpm-workspace.yaml or package.json workspaces) is
211
+ * found walking up the directory tree, OR if no `.meta/package.meta.json`
212
+ * files are present in the workspace packages.
213
+ *
214
+ * The returned Workspace has every package's manifest pre-loaded; callers
215
+ * can look up peers by name or canonical metaobjects ref.
216
+ */
217
+ export async function discoverWorkspace(cwd: string): Promise<Workspace | undefined> {
218
+ const config = await findWorkspaceConfig(cwd);
219
+ if (config === undefined) return undefined;
220
+
221
+ // Expand all workspace globs
222
+ const packageDirs: string[] = [];
223
+ for (const glob of config.globs) {
224
+ packageDirs.push(...(await expandGlob(config.root, glob)));
225
+ }
226
+
227
+ // Also include the workspace root itself if it has a .meta/ —
228
+ // common pattern for root-level "the app" metadata.
229
+ const rootMetaDir = join(config.root, ".meta");
230
+ try {
231
+ const s = await stat(rootMetaDir);
232
+ if (s.isDirectory()) packageDirs.push(config.root);
233
+ } catch {
234
+ // no root-level .meta — fine
235
+ }
236
+
237
+ // For each package dir, look for .meta/package.meta.json
238
+ const packages: WorkspacePackage[] = [];
239
+ const seenDirs = new Set<string>();
240
+ for (const pkgDir of packageDirs) {
241
+ if (seenDirs.has(pkgDir)) continue;
242
+ seenDirs.add(pkgDir);
243
+ const metaDir = join(pkgDir, ".meta");
244
+ try {
245
+ const s = await stat(metaDir);
246
+ if (!s.isDirectory()) continue;
247
+ } catch {
248
+ continue;
249
+ }
250
+ const manifestPath = join(metaDir, PACKAGE_MANIFEST_FILE);
251
+ let raw: string;
252
+ try {
253
+ raw = await readFile(manifestPath, "utf8");
254
+ } catch {
255
+ continue; // no manifest = not a metadata package
256
+ }
257
+ const manifest = PackageManifestSchema.parse(JSON.parse(raw));
258
+ const canonical = resolveMetaobjectsPackage(manifest);
259
+ if (canonical === undefined) {
260
+ throw new Error(
261
+ `package.meta.json in ${metaDir} has a name "${manifest.name}" that cannot be auto-derived to a canonical ref; declare metaobjectsPackage explicitly`,
262
+ );
263
+ }
264
+ packages.push({ metaDir, manifest, metaobjectsPackage: canonical });
265
+ }
266
+
267
+ if (packages.length === 0) return undefined;
268
+
269
+ return {
270
+ root: config.root,
271
+ packages,
272
+ findByName(name: string) {
273
+ return packages.find((p) => p.manifest.name === name);
274
+ },
275
+ findByMetaobjectsPackage(ref: string) {
276
+ return packages.find((p) => p.metaobjectsPackage === ref);
277
+ },
278
+ };
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Resolve extends graph
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /**
286
+ * For a given package (specified by its .meta/ directory), walk its `extends:`
287
+ * deps transitively via the workspace and return the full ordered list of
288
+ * packages to load — dependencies first, the target package last.
289
+ *
290
+ * Throws on:
291
+ * - Missing package referenced by extends:
292
+ * - Cycles in the extends graph
293
+ */
294
+ export function resolveExtendsOrder(
295
+ workspace: Workspace,
296
+ startMetaDir: string,
297
+ ): WorkspacePackage[] {
298
+ const startPkg = workspace.packages.find((p) => p.metaDir === startMetaDir);
299
+ if (startPkg === undefined) {
300
+ throw new Error(
301
+ `metadata package at ${startMetaDir} is not part of the discovered workspace (root: ${workspace.root})`,
302
+ );
303
+ }
304
+
305
+ const ordered: WorkspacePackage[] = [];
306
+ const visited = new Set<string>();
307
+ const visiting = new Set<string>();
308
+
309
+ function visit(pkg: WorkspacePackage): void {
310
+ if (visited.has(pkg.metaobjectsPackage)) return;
311
+ if (visiting.has(pkg.metaobjectsPackage)) {
312
+ throw new Error(
313
+ `cycle in extends graph: ${[...visiting, pkg.metaobjectsPackage].join(" → ")}`,
314
+ );
315
+ }
316
+ visiting.add(pkg.metaobjectsPackage);
317
+ for (const dep of pkg.manifest.extends) {
318
+ const depPkg = workspace.findByName(dep) ?? workspace.findByMetaobjectsPackage(dep);
319
+ if (depPkg === undefined) {
320
+ throw new Error(
321
+ `package "${pkg.manifest.name}" extends "${dep}" but that package was not found in the workspace`,
322
+ );
323
+ }
324
+ visit(depPkg);
325
+ }
326
+ visiting.delete(pkg.metaobjectsPackage);
327
+ visited.add(pkg.metaobjectsPackage);
328
+ ordered.push(pkg);
329
+ }
330
+
331
+ visit(startPkg);
332
+ return ordered;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Diagnostic helpers
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /** Human-readable label for a workspace package — useful for error messages. */
340
+ export function packageLabel(pkg: WorkspacePackage): string {
341
+ return `${pkg.manifest.name} (${pkg.metaobjectsPackage}) at ${basename(dirname(pkg.metaDir))}`;
342
+ }