@metaobjectsdev/sdk 0.23.2 → 0.24.0

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 (75) hide show
  1. package/agent-context/skills/metaobjects-audit/SKILL.md +20 -7
  2. package/agent-context/skills/metaobjects-audit/references/capability-checklist.md +64 -18
  3. package/agent-context/skills/metaobjects-audit/references/csharp.md +2 -1
  4. package/agent-context/skills/metaobjects-audit/references/java.md +3 -2
  5. package/agent-context/skills/metaobjects-audit/references/kotlin.md +2 -1
  6. package/agent-context/skills/metaobjects-audit/references/requirements.md +3 -4
  7. package/agent-context/skills/metaobjects-audit/references/typescript.md +1 -1
  8. package/agent-context/skills/metaobjects-authoring/SKILL.md +28 -3
  9. package/agent-context/skills/metaobjects-authoring/references/requirements.md +49 -23
  10. package/agent-context/skills/metaobjects-codegen/SKILL.md +4 -3
  11. package/agent-context/skills/metaobjects-codegen/references/csharp.md +2 -2
  12. package/agent-context/skills/metaobjects-codegen/references/java.md +3 -3
  13. package/agent-context/skills/metaobjects-codegen/references/kotlin.md +4 -4
  14. package/agent-context/skills/metaobjects-codegen/references/python.md +2 -2
  15. package/agent-context/skills/metaobjects-codegen/references/typescript.md +3 -3
  16. package/agent-context/skills/metaobjects-fit-assessment/SKILL.md +4 -4
  17. package/agent-context/skills/metaobjects-prompts/SKILL.md +53 -32
  18. package/agent-context/skills/metaobjects-prompts/references/csharp.md +26 -21
  19. package/agent-context/skills/metaobjects-prompts/references/java.md +24 -18
  20. package/agent-context/skills/metaobjects-prompts/references/kotlin.md +25 -20
  21. package/agent-context/skills/metaobjects-prompts/references/python.md +68 -26
  22. package/agent-context/skills/metaobjects-prompts/references/typescript.md +29 -23
  23. package/agent-context/skills/metaobjects-verify/references/migration.md +1 -1
  24. package/agent-context/skills/metaobjects-verify/references/requirements.md +36 -22
  25. package/agent-context/templates/always-on.md.mustache +3 -1
  26. package/dist/agent-docs/body.d.ts +1 -1
  27. package/dist/agent-docs/body.d.ts.map +1 -1
  28. package/dist/agent-docs/body.js +24 -2
  29. package/dist/agent-docs/body.js.map +1 -1
  30. package/dist/collection.d.ts +71 -0
  31. package/dist/collection.d.ts.map +1 -0
  32. package/dist/collection.js +121 -0
  33. package/dist/collection.js.map +1 -0
  34. package/dist/config.d.ts +51 -21
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +76 -10
  37. package/dist/config.js.map +1 -1
  38. package/dist/discovery.d.ts +43 -0
  39. package/dist/discovery.d.ts.map +1 -0
  40. package/dist/discovery.js +100 -0
  41. package/dist/discovery.js.map +1 -0
  42. package/dist/index.d.ts +10 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +17 -4
  45. package/dist/index.js.map +1 -1
  46. package/dist/memory.d.ts +42 -19
  47. package/dist/memory.d.ts.map +1 -1
  48. package/dist/memory.js +38 -95
  49. package/dist/memory.js.map +1 -1
  50. package/dist/metadata-files.d.ts +67 -0
  51. package/dist/metadata-files.d.ts.map +1 -0
  52. package/dist/metadata-files.js +155 -0
  53. package/dist/metadata-files.js.map +1 -0
  54. package/dist/records/any.d.ts +8 -8
  55. package/dist/records/decision.d.ts +2 -2
  56. package/dist/records/glossary.d.ts +4 -4
  57. package/dist/records/principle.d.ts +2 -2
  58. package/dist/scope.d.ts +16 -0
  59. package/dist/scope.d.ts.map +1 -0
  60. package/dist/scope.js +75 -0
  61. package/dist/scope.js.map +1 -0
  62. package/dist/sources.d.ts +86 -0
  63. package/dist/sources.d.ts.map +1 -0
  64. package/dist/sources.js +130 -0
  65. package/dist/sources.js.map +1 -0
  66. package/package.json +2 -2
  67. package/src/agent-docs/body.ts +24 -2
  68. package/src/collection.ts +183 -0
  69. package/src/config.ts +80 -12
  70. package/src/discovery.ts +110 -0
  71. package/src/index.ts +26 -9
  72. package/src/memory.ts +65 -99
  73. package/src/metadata-files.ts +158 -0
  74. package/src/scope.ts +95 -0
  75. package/src/sources.ts +160 -0
package/src/memory.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { join } from "node:path";
2
- import { readdir, stat } from "node:fs/promises";
3
1
  import {
4
2
  composeRegistry,
5
3
  coreProviders,
@@ -8,21 +6,8 @@ import {
8
6
  type MetaRoot,
9
7
  } from "@metaobjectsdev/metadata";
10
8
  import { FileSource } from "@metaobjectsdev/metadata/core";
9
+ import { resolveCollection } from "./collection.js";
11
10
  import { forgeTypesProvider } from "./forge-types.js";
12
- import { discoverWorkspace, resolveExtendsOrder } from "./workspace.js";
13
-
14
- /**
15
- * Default directory name (relative to project root) where metadata JSON files
16
- * are scanned. Scaffold via `meta init`; the directory is committed to git.
17
- */
18
- export const DEFAULT_METADATA_DIR = "metaobjects";
19
-
20
- /**
21
- * Default directory name (relative to project root) for MetaObjects' own
22
- * runtime state: config.json, .gen-state/, package.meta.json, agent docs.
23
- * Scaffold via `meta init`; most contents are committed to git.
24
- */
25
- export const DEFAULT_METAOBJECTS_DIR = ".metaobjects";
26
11
 
27
12
  /**
28
13
  * Options for {@link loadMemory}. Consumers can supply additional
@@ -50,6 +35,31 @@ export interface LoadMemoryOptions {
50
35
  * the `meta verify` command opts in to `true` (strict-by-default, #96).
51
36
  */
52
37
  strict?: boolean;
38
+ /**
39
+ * An already-resolved, absolute metadata file list — normally
40
+ * `resolveCollection(...).files`. When supplied, `loadMemory` loads exactly
41
+ * these files and resolves nothing itself.
42
+ *
43
+ * Omitting it is not a different WAY of finding metadata, only a different
44
+ * place the same resolution happens: `loadMemory` then calls
45
+ * `resolveCollection(repoRoot)` itself. Passing it saves the second
46
+ * resolution when the caller already holds a collection (every routed CLI
47
+ * command does) and lets a caller load a file set it computed some other
48
+ * way; it can no longer diverge from what the config declares.
49
+ */
50
+ files?: readonly string[];
51
+ /**
52
+ * MetaObjects-shipped library packages to load ALONGSIDE the project's own files
53
+ * (e.g. `["ai"]` for `metaobjects::ai::LlmCallBase`). Prepended, so an
54
+ * `extends: "metaobjects::ai::LlmCallBase"` in project metadata resolves.
55
+ *
56
+ * Opt-in rather than always-on: a library package registers real top-level nodes, and
57
+ * a project that never references one should not have them appear in its model, its
58
+ * generated output or its docs. Without this the CLI could not load the metadata that
59
+ * shipped generators like `trace-helper` exist to consume, so the generator was
60
+ * reachable from the command line while its input was not (#333).
61
+ */
62
+ libraries?: readonly string[];
53
63
  }
54
64
 
55
65
  /** Default provider bundle threaded by {@link loadMemory} when no options
@@ -62,10 +72,15 @@ export const defaultLoadMemoryProviders: readonly MetaDataTypeProvider[] = [
62
72
  ];
63
73
 
64
74
  /**
65
- * Load all metadata files from `<repoRoot>/metaobjects/` into a single
66
- * MetaData. If `<repoRoot>/.meta/package.meta.json` declares `extends:` deps
67
- * and a workspace can be discovered (pnpm-workspace.yaml or package.json
68
- * workspaces), peer packages are loaded too in topological dep-first order.
75
+ * Load a project's metadata into a single MetaData tree.
76
+ *
77
+ * Which files those are is `resolveCollection`'s decision, never this
78
+ * function's: with no {@link LoadMemoryOptions.files} it calls
79
+ * `resolveCollection(repoRoot)` — nearest-ancestor `.metaobjects/config.json`,
80
+ * then that config's declared `sources`, falling back to the default source
81
+ * directory only when a project declares none. `loadMemory` names no directory
82
+ * of its own, so a caller cannot end up loading from somewhere the rest of the
83
+ * toolchain does not.
69
84
  *
70
85
  * Excludes `_pending/`. Registers metaobjects core types plus Meta Forge's
71
86
  * descriptive top-level types (decision, principle, etc.) so mixed content
@@ -73,11 +88,15 @@ export const defaultLoadMemoryProviders: readonly MetaDataTypeProvider[] = [
73
88
  * {@link LoadMemoryOptions.providers}) are composed AFTER the defaults so
74
89
  * they may depend on core/forge ids.
75
90
  *
76
- * Throws if `metaobjects/` doesn't exist (callers should run `meta init`).
91
+ * Throws `ERR_COLLECTION_NOT_FOUND` when nothing resolves (callers should run
92
+ * `meta init`), unless `options.files` is supplied.
77
93
  *
78
- * @param repoRoot The project's working-directory root (e.g. process.cwd()).
79
- * `loadMemory` resolves `metaobjects/` and (if workspace-aware) the
80
- * transitive `extends:` graph automatically.
94
+ * @param repoRoot Where resolution STARTS — the working directory, typically
95
+ * `process.cwd()`. The walk goes up from here for the governing config, so
96
+ * this need not be the project root itself.
97
+ * **Ignored entirely when `options.files` is supplied**: that list is already
98
+ * resolved, so nothing reads this path. Every routed CLI command passes both,
99
+ * and the argument is inert at all of them.
81
100
  * @param options Optional {@link LoadMemoryOptions} — supply additional
82
101
  * providers or replace the default bundle entirely.
83
102
  */
@@ -99,16 +118,33 @@ export async function loadMemory(
99
118
  }
100
119
  const registry = composeRegistry(providers);
101
120
 
102
- // Collect all metadata file paths to load. Order matters for the parser's
103
- // deferred-resolution pass (it parses in array order, then resolves supers
104
- // against the merged tree afterwards) dep packages first, current last.
105
- const paths = await collectMetadataPaths(repoRoot);
121
+ // Both arms are `resolveCollection`'s answer one already computed by the
122
+ // caller, one computed here. There is no third way to find metadata, and
123
+ // that is the whole of this line's design: the previous no-`files` arm
124
+ // scanned `<repoRoot>/<default dir>` directly, so a caller that copied the
125
+ // routed shape but forgot `files` silently loaded from a directory the
126
+ // project's config may never have mentioned.
127
+ const paths = options?.files !== undefined
128
+ ? [...options.files]
129
+ : [...(await resolveCollection(repoRoot)).files];
106
130
 
107
131
  const loader = new MetaDataLoader({
108
132
  registry,
109
133
  ...(options?.strict === true ? { strict: true } : {}),
110
134
  });
111
- const result = await loader.load(paths.map((p) => new FileSource(p)));
135
+
136
+ // Library sources are imported lazily and only when asked for — the same reason
137
+ // `MetaDataLoader.fromDirectory` does it. `library-sources.ts` reads `node:fs`, so a
138
+ // static import from a root-reachable module drags Node built-ins into every consumer's
139
+ // graph; that is the #287 bundle defect, and the `./library` subpath exists for exactly
140
+ // the reason `./constants` does. Prepended, so a project's `extends` onto a
141
+ // library-shipped abstract base resolves — super resolution is order-independent, but
142
+ // prepending is the deterministic choice and matches `fromDirectory`.
143
+ const libSources =
144
+ options?.libraries !== undefined && options.libraries.length > 0
145
+ ? (await import("@metaobjectsdev/metadata/library")).librarySources([...options.libraries])
146
+ : [];
147
+ const result = await loader.load([...libSources, ...paths.map((p) => new FileSource(p))]);
112
148
 
113
149
  if (result.errors.length > 0) {
114
150
  const first = result.errors[0]!;
@@ -117,73 +153,3 @@ export async function loadMemory(
117
153
 
118
154
  return result.root;
119
155
  }
120
-
121
- // Dep packages' metaobjects/ files first (topological order), then current.
122
- async function collectMetadataPaths(repoRoot: string): Promise<string[]> {
123
- const currentMetaDir = join(repoRoot, ".meta");
124
- const ws = await discoverWorkspace(repoRoot);
125
-
126
- // Workspace path: walk extends, load dep metaobjects/ dirs first
127
- if (ws !== undefined) {
128
- const currentPkg = ws.packages.find((p) => p.metaDir === currentMetaDir);
129
- if (currentPkg !== undefined && currentPkg.manifest.extends.length > 0) {
130
- const ordered = resolveExtendsOrder(ws, currentMetaDir);
131
- const paths: string[] = [];
132
- for (const pkg of ordered) {
133
- // Each workspace package's metadata lives alongside its .meta/ dir
134
- const pkgRoot = join(pkg.metaDir, "..");
135
- paths.push(...(await listMetadataFiles(join(pkgRoot, DEFAULT_METADATA_DIR))));
136
- }
137
- return paths;
138
- }
139
- }
140
-
141
- // Single-package path: scan metaobjects/ at the project root
142
- return listMetadataFiles(join(repoRoot, DEFAULT_METADATA_DIR));
143
- }
144
-
145
- /**
146
- * Recursively list metadata files (*.json, *.yaml, *.yml) under a directory,
147
- * excluding _pending/ at any level. Subdirectories (e.g. projections/) are
148
- * walked depth-first. Files within a directory are sorted alphabetically for
149
- * deterministic load order; subdirectories are visited after files at the
150
- * same level.
151
- *
152
- * Format selection (parsing) happens downstream in `FileSource` from
153
- * `@metaobjectsdev/metadata`, which infers the parser from file extension.
154
- */
155
- async function listMetadataFiles(dir: string): Promise<string[]> {
156
- let entries: string[];
157
- try {
158
- entries = await readdir(dir);
159
- } catch (err) {
160
- throw new Error(`loadMemory: cannot read ${dir}: ${(err as Error).message}`);
161
- }
162
- const paths: string[] = [];
163
- const subdirs: string[] = [];
164
- // #188: sort the raw `readdir` entries so file order is deterministic across
165
- // runtimes/filesystems (Node vs Bun return different `readdir` orders), matching
166
- // this function's docstring and the metadata package's own `DirectorySource`.
167
- // (Resolution is now order-INDEPENDENT — super-resolve.ts #188 — so this is the
168
- // deterministic-enumeration FLOOR, not the fix; it keeps every derived artifact
169
- // that preserves declaration order, e.g. serialization, stable across runtimes.)
170
- for (const entry of [...entries].sort()) {
171
- if (entry === "_pending") continue;
172
- const full = join(dir, entry);
173
- const s = await stat(full);
174
- if (s.isDirectory()) {
175
- subdirs.push(full);
176
- } else if (s.isFile() && isMetadataFile(entry)) {
177
- paths.push(full);
178
- }
179
- }
180
- // Recurse into subdirectories after collecting files at this level
181
- for (const sub of subdirs.sort()) {
182
- paths.push(...(await listMetadataFiles(sub)));
183
- }
184
- return paths;
185
- }
186
-
187
- function isMetadataFile(name: string): boolean {
188
- return name.endsWith(".json") || name.endsWith(".yaml") || name.endsWith(".yml");
189
- }
@@ -0,0 +1,158 @@
1
+ // server/typescript/packages/sdk/src/metadata-files.ts
2
+ //
3
+ // The project's default directory names, what counts as a metadata file, and
4
+ // the one walk that turns a directory into an ordered file list.
5
+ //
6
+ // **This module imports nothing from its siblings, and that is the point.**
7
+ // `resolveCollection` (`collection.ts`) is the single authority on where
8
+ // metadata lives, so `memory.ts`'s `loadMemory` must call it — while
9
+ // `collection.ts` and `sources.ts` need the constants and the walk below.
10
+ // Homing those in `memory.ts` closes an ESM cycle whose failure mode is not a
11
+ // warning but a crash: `DEFAULT_SOURCES` (`sources.ts`) reads
12
+ // `DEFAULT_METADATA_DIR` at module top level, so the cycle surfaces as
13
+ // `ReferenceError: Cannot access 'DEFAULT_METADATA_DIR' before initialization`
14
+ // on whichever module the entry point happens to reach first. A leaf both
15
+ // sides import is the fix; a lazy `await import()` inside `loadMemory` is not
16
+ // — that hides the cycle rather than removing it.
17
+ import { extname, join } from "node:path";
18
+ import { readdir, realpath, stat } from "node:fs/promises";
19
+
20
+ /**
21
+ * The DEFAULT value of `sources` — the directory scanned when
22
+ * `.metaobjects/config.json` declares no sources. Scaffold via `meta init`;
23
+ * the directory is committed to git.
24
+ *
25
+ * **A default, and nothing else.** No read path may assume a directory of this
26
+ * name exists or is where metadata lives: that question is answered by
27
+ * `resolveCollection`, which applies this constant exactly once (via
28
+ * `DEFAULT_SOURCES` in `sources.ts`) when a project declares nothing. A
29
+ * project that declares `sources` may put its metadata anywhere, and every
30
+ * command follows the config. `test/no-hardcoded-metadata-dir.test.ts` is the
31
+ * enforcer.
32
+ */
33
+ export const DEFAULT_METADATA_DIR = "metaobjects";
34
+
35
+ /**
36
+ * Default directory name (relative to project root) for MetaObjects' own
37
+ * runtime state: config.json, .gen-state/, package.meta.json, agent docs.
38
+ * Scaffold via `meta init`; most contents are committed to git.
39
+ *
40
+ * Unlike {@link DEFAULT_METADATA_DIR} this one IS a fixed convention — it is
41
+ * where the config that answers "where is the metadata?" lives, so it cannot
42
+ * itself be configured.
43
+ */
44
+ export const DEFAULT_METAOBJECTS_DIR = ".metaobjects";
45
+
46
+ /** Recognized metadata file extensions, matched case-insensitively — mirrors
47
+ * `DirectorySource` in `@metaobjectsdev/metadata`, which checks
48
+ * `extname().toLowerCase()`. The single definition every metadata-file
49
+ * walker in this package uses — and since `resolveSources` (`sources.ts`)
50
+ * calls {@link listMetadataFiles} outright rather than keeping a second
51
+ * recursive walk of its own, there is exactly one walker to keep honest. */
52
+ export const METADATA_EXTENSIONS = new Set([".json", ".yaml", ".yml"]);
53
+
54
+ export function isMetadataFile(name: string): boolean {
55
+ return METADATA_EXTENSIONS.has(extname(name).toLowerCase());
56
+ }
57
+
58
+ /** Directory excluded at every level of {@link listMetadataFiles} — drafts
59
+ * that are deliberately not part of the loaded model. */
60
+ const PENDING_DIR = "_pending";
61
+
62
+ /**
63
+ * Recursively list metadata files (*.json, *.yaml, *.yml, matched
64
+ * case-insensitively — see `isMetadataFile` above) under a directory,
65
+ * excluding _pending/ at any level. Subdirectories (e.g. projections/) are
66
+ * walked depth-first. Files within a directory are sorted alphabetically for
67
+ * deterministic load order; subdirectories are visited AFTER the files at the
68
+ * same level.
69
+ *
70
+ * That per-level rule is a contract, not an implementation detail. This is the
71
+ * order production has always handed the loader, and declaration order survives
72
+ * into generated output: `codegen-ts`'s barrel emits from `root.objects()`
73
+ * order, and so do the shared `enums.ts`, `meta docs` page ordering and `meta
74
+ * export`'s `canonicalSerialize` sibling order. A flat lexicographic sort of
75
+ * absolute paths is NOT the same list — it disagrees whenever a subdirectory
76
+ * name sorts before a sibling file (`common/` before `meta.users.json`) — so
77
+ * `resolveSources` calls this function rather than re-walking and re-sorting.
78
+ * Pinned by `test/source-order.test.ts`.
79
+ *
80
+ * Exported for that gate and for `sources.ts`; not re-exported from the package
81
+ * index — `resolveCollection` is the public door.
82
+ *
83
+ * An entry whose `stat` fails (a dangling symlink, a TOCTOU removal between
84
+ * `readdir` and `stat`, an EACCES entry) is SKIPPED, matching `DirectorySource`
85
+ * in `@metaobjectsdev/metadata`, which this walk otherwise mirrors. A failure to
86
+ * read the directory itself still throws — that is the "you have no metadata
87
+ * here" case callers report.
88
+ *
89
+ * A symlink CYCLE (e.g. `metaobjects/link -> ..`) is a loud error rather than
90
+ * unbounded recursion — see {@link listMetadataFilesGuarded}.
91
+ *
92
+ * Format selection (parsing) happens downstream in `FileSource` from
93
+ * `@metaobjectsdev/metadata`, which infers the parser from file extension.
94
+ */
95
+ export async function listMetadataFiles(dir: string): Promise<string[]> {
96
+ return listMetadataFilesGuarded(dir, new Set());
97
+ }
98
+
99
+ /**
100
+ * {@link listMetadataFiles}'s recursive worker, carrying the REAL (symlink-
101
+ * resolved) ancestor directories already on this walk branch.
102
+ *
103
+ * This walk follows symlinked directories on purpose (`stat`, not `lstat`,
104
+ * below — matching `DirectorySource` in `@metaobjectsdev/metadata`), so an
105
+ * unguarded directory symlink that revisits an ancestor recurses forever:
106
+ * Java and Python both added this exact guard when this PR promoted
107
+ * symlink-following to a cross-port contract; the TypeScript reference itself
108
+ * did not, even though the corpus cites it as authoritative. `ancestors` is
109
+ * extended only on the recursive call (never mutated in place), so it
110
+ * reflects the current branch, not siblings visited earlier at the same
111
+ * level — a directory legitimately reachable via two different symlinked
112
+ * paths (not a cycle) is not falsely rejected.
113
+ */
114
+ async function listMetadataFilesGuarded(dir: string, ancestors: ReadonlySet<string>): Promise<string[]> {
115
+ // `realpath` failing (e.g. `dir` vanished between being listed and now) is
116
+ // not this guard's problem — fall back to the given path and let `readdir`
117
+ // below raise its own coded error.
118
+ const real = await realpath(dir).catch(() => dir);
119
+ if (ancestors.has(real)) {
120
+ throw new Error(`symlink loop detected while expanding metadata directory: ${dir} revisits ${real}`);
121
+ }
122
+ const nextAncestors = new Set(ancestors);
123
+ nextAncestors.add(real);
124
+
125
+ let entries: string[];
126
+ try {
127
+ entries = await readdir(dir);
128
+ } catch (err) {
129
+ throw new Error(`cannot read metadata directory ${dir}: ${(err as Error).message}`);
130
+ }
131
+ const paths: string[] = [];
132
+ const subdirs: string[] = [];
133
+ // #188: sort the raw `readdir` entries so file order is deterministic across
134
+ // runtimes/filesystems (Node vs Bun return different `readdir` orders), matching
135
+ // this function's docstring and the metadata package's own `DirectorySource`.
136
+ // (Resolution is now order-INDEPENDENT — super-resolve.ts #188 — so this is the
137
+ // deterministic-enumeration FLOOR, not the fix; it keeps every derived artifact
138
+ // that preserves declaration order, e.g. serialization, stable across runtimes.)
139
+ for (const entry of [...entries].sort()) {
140
+ if (entry === PENDING_DIR) continue;
141
+ const full = join(dir, entry);
142
+ // `stat` (not `lstat`/`Dirent.isDirectory()`) so a symlinked subdirectory is
143
+ // traversed — `DirectorySource` has always followed symlinks this way.
144
+ const s = await stat(full).catch(() => undefined);
145
+ if (s === undefined) continue;
146
+ if (s.isDirectory()) {
147
+ subdirs.push(full);
148
+ } else if (s.isFile() && isMetadataFile(entry)) {
149
+ paths.push(full);
150
+ }
151
+ }
152
+ // Recurse into subdirectories after collecting files at this level.
153
+ // `subdirs` is already in sorted order (built from the sorted `entries` above).
154
+ for (const sub of subdirs) {
155
+ paths.push(...(await listMetadataFilesGuarded(sub, nextAncestors)));
156
+ }
157
+ return paths;
158
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,95 @@
1
+ // server/typescript/packages/sdk/src/scope.ts
2
+ //
3
+ // Phase-1 metadata-source-resolution — the scope pattern engine.
4
+ //
5
+ // A pure, no-I/O module deciding whether a fully-qualified node name falls
6
+ // inside a consumer's declared `include`/`exclude` scope. Source resolution
7
+ // and discovery (later phase-1 tasks) build on this; a cross-language
8
+ // conformance corpus pins its semantics, so exact pattern behavior matters.
9
+
10
+ // NOTE: PACKAGE_SEPARATOR is NOT re-exported from the browser-safe
11
+ // `@metaobjectsdev/metadata/constants` barrel (that barrel only re-exports
12
+ // the per-concern `*-constants.ts` modules; `PACKAGE_SEPARATOR` lives in
13
+ // `shared/structural.ts`, exported from the package root). This package
14
+ // (`@metaobjectsdev/sdk`) is server-side, not a `client/web/**` browser
15
+ // package, so importing metamodel values from the root — the same thing
16
+ // `memory.ts` and `forge-types.ts` in this package already do — is correct.
17
+ import { PACKAGE_SEPARATOR, ParseError, codeSource } from "@metaobjectsdev/metadata";
18
+
19
+ /** A consumer-side output filter over fully-qualified node names. */
20
+ export interface Scope {
21
+ /** Absent or empty means "everything". */
22
+ readonly include?: readonly string[];
23
+ /** Applied after `include`. */
24
+ readonly exclude?: readonly string[];
25
+ }
26
+
27
+ export interface CompiledScope {
28
+ readonly include: readonly RegExp[];
29
+ readonly exclude: readonly RegExp[];
30
+ }
31
+
32
+ /** One package segment: any run of characters containing no separator char. */
33
+ const SEGMENT = "[^:]+";
34
+ /** One or more segments, separator-joined — the `**` expansion. */
35
+ const SEGMENTS = `${SEGMENT}(?:${PACKAGE_SEPARATOR}${SEGMENT})*`;
36
+
37
+ function escapeLiteral(text: string): string {
38
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
+ }
40
+
41
+ /** Compile one segment. `**` spans segments; `*` never crosses a separator. */
42
+ function compileSegment(segment: string, pattern: string): string {
43
+ if (segment.length === 0) {
44
+ throw new ParseError(`empty segment in scope pattern "${pattern}"`, {
45
+ code: "ERR_SCOPE_PATTERN_INVALID",
46
+ source: codeSource("compileSegment"),
47
+ });
48
+ }
49
+ // A segment surviving the split on the two-character PACKAGE_SEPARATOR
50
+ // ("::") can still contain a lone ":" when the pattern has an odd colon
51
+ // run — e.g. "acme:::Order".split("::") => ["acme", ":Order"]. SEGMENT
52
+ // ([^:]+) already excludes ":" from a well-formed segment, so a leftover
53
+ // ":" here means the separator was malformed, not that ":" is meant
54
+ // literally. Left unchecked, escapeLiteral treats it as a literal
55
+ // character and compiles a regex requiring three colons in a row — which
56
+ // no legal "::"-joined fully-qualified name can ever contain, so the
57
+ // pattern silently matches nothing instead of failing loud.
58
+ if (segment.includes(":")) {
59
+ throw new ParseError(
60
+ `scope pattern "${pattern}" has a malformed separator (an odd run of ":") — segments are joined by "::", never a single ":"`,
61
+ { code: "ERR_SCOPE_PATTERN_INVALID", source: codeSource("compileSegment") },
62
+ );
63
+ }
64
+ if (segment === "**") return `(?:${SEGMENTS})`;
65
+ // `*` inside a segment matches any characters except the separator char.
66
+ return segment.split("*").map(escapeLiteral).join("[^:]*");
67
+ }
68
+
69
+ export function compilePattern(pattern: string): RegExp {
70
+ if (pattern.length === 0) {
71
+ throw new ParseError(`scope pattern must not be empty`, {
72
+ code: "ERR_SCOPE_PATTERN_INVALID",
73
+ source: codeSource("compilePattern"),
74
+ });
75
+ }
76
+ const body = pattern
77
+ .split(PACKAGE_SEPARATOR)
78
+ .map((segment) => compileSegment(segment, pattern))
79
+ .join(PACKAGE_SEPARATOR);
80
+ return new RegExp(`^${body}$`);
81
+ }
82
+
83
+ export function compileScope(scope: Scope): CompiledScope {
84
+ return {
85
+ include: (scope.include ?? []).map(compilePattern),
86
+ exclude: (scope.exclude ?? []).map(compilePattern),
87
+ };
88
+ }
89
+
90
+ /** True when `fqn` is inside the scope. An empty `include` means everything. */
91
+ export function matchesScope(fqn: string, compiled: CompiledScope): boolean {
92
+ const included = compiled.include.length === 0 || compiled.include.some((re) => re.test(fqn));
93
+ if (!included) return false;
94
+ return !compiled.exclude.some((re) => re.test(fqn));
95
+ }
package/src/sources.ts ADDED
@@ -0,0 +1,160 @@
1
+ // server/typescript/packages/sdk/src/sources.ts
2
+ //
3
+ // Phase-1 metadata-source-resolution — source spec resolution.
4
+ //
5
+ // Turns a declared source SET (`.metaobjects/config.json`'s `sources`) into a
6
+ // canonically-ordered, de-duplicated list of metadata file paths. The FULL
7
+ // result — including which spec each entry attributes to — is a pure
8
+ // function of the source SET, never of declaration order: permuting `specs`
9
+ // cannot change the output, even when two specs overlap on the same file.
10
+ // `test/order-independence.test.ts` pins that (the design's linchpin), so the
11
+ // canonical spec ordering below is load-bearing.
12
+ //
13
+ // Canonical is NOT the same as "flat-sorted". Within one directory spec the
14
+ // order is `listMetadataFiles`'s (metadata-files.ts) — files at a level, then
15
+ // that level's subdirectories, depth-first — because that is the order production
16
+ // has always handed the loader, and declaration order survives into generated
17
+ // output (the barrel's export list, the shared `enums.ts`, `meta docs` page
18
+ // order, `meta export`'s sibling order). A flat sort of absolute paths
19
+ // silently reorders any project with a subdirectory whose name sorts before a
20
+ // sibling file. Across specs, order is decided by spec CONTENT, which is what
21
+ // keeps the whole result permutation-invariant. `test/source-order.test.ts`
22
+ // pins both halves.
23
+ import { stat } from "node:fs/promises";
24
+ import { isAbsolute, resolve } from "node:path";
25
+ import { ParseError, codeSource } from "@metaobjectsdev/metadata";
26
+ import { DEFAULT_METADATA_DIR, listMetadataFiles } from "./metadata-files.js";
27
+
28
+ /** Tagged union of source kinds. `resource` and `package` are declared now so
29
+ * the config shape is stable across phases; only `path` resolves in phase 1 —
30
+ * `resource`/`package` throw `ERR_SOURCE_KIND_UNSUPPORTED`. */
31
+ export type SourceSpec =
32
+ | { readonly path: string }
33
+ | { readonly resource: string }
34
+ | { readonly package: string };
35
+
36
+ export interface ResolvedSource {
37
+ /** Absolute path of one metadata file. */
38
+ readonly file: string;
39
+ /** The spec that contributed it — provenance for diagnostics. */
40
+ readonly spec: SourceSpec;
41
+ }
42
+
43
+ /** Used when `sources` is absent or empty in `.metaobjects/config.json`. A
44
+ * DEFAULT, never a requirement — a project that declares `sources` explicitly
45
+ * need not include the default directory at all. Built from
46
+ * `DEFAULT_METADATA_DIR` (`metadata-files.ts`'s single definition) rather than
47
+ * restating that name here: a second independent encoding of the same
48
+ * default would let `resolveCollection`'s "does the default dir exist"
49
+ * check (`collection.ts`) desync from what `resolveSources` actually
50
+ * resolves the moment the default ever changed — silently reproducing the
51
+ * "two code paths disagree about where metadata lives" class of bug this
52
+ * whole mechanism exists to eliminate. */
53
+ export const DEFAULT_SOURCES: readonly SourceSpec[] = [{ path: DEFAULT_METADATA_DIR }];
54
+
55
+ /** Narrows `spec` to its `path` arm, throwing `ERR_SOURCE_KIND_UNSUPPORTED`
56
+ * for `resource`/`package` — phase 1 resolves `path` only. Returns rather than
57
+ * asserting so one call both validates and narrows: an `asserts` signature has
58
+ * to be re-invoked wherever TypeScript's control-flow analysis cannot carry the
59
+ * narrowing, which is a language workaround masquerading as a second check. */
60
+ function toPathSpec(spec: SourceSpec): { readonly path: string } {
61
+ if ("path" in spec) return spec;
62
+ const kind = "resource" in spec ? "resource" : "package";
63
+ throw new ParseError(
64
+ `source kind "${kind}" is not supported by this toolchain yet; use a "path" source`,
65
+ { code: "ERR_SOURCE_KIND_UNSUPPORTED", source: codeSource("resolveSources") },
66
+ );
67
+ }
68
+
69
+ /**
70
+ * The declared source SET in CANONICAL order — kind-validated, then sorted by
71
+ * spec CONTENT rather than by declaration order.
72
+ *
73
+ * This is the ONE place declaration order is discarded, and the module's "pure
74
+ * function of the SET" invariant rests on it: the emitted file order, the spec
75
+ * attributed to a file two specs both reach, and which of several unresolvable
76
+ * paths reports its `ERR_SOURCE_UNRESOLVED` first are all decided here.
77
+ * Validation runs across the WHOLE list before any sorting or filesystem I/O —
78
+ * interleaved with resolution, which error code came back would depend on
79
+ * declaration order, contradicting that same invariant.
80
+ *
81
+ * Exported because `resolveCollection` derives `sourceRoots` from the declared
82
+ * specs and must use this identical ordering; a second sort would be a second
83
+ * definition of "canonical".
84
+ */
85
+ export function orderedPathSpecs(specs: readonly SourceSpec[]): { readonly path: string }[] {
86
+ return specs.map(toPathSpec).sort((a, b) => {
87
+ const [ja, jb] = [JSON.stringify(a), JSON.stringify(b)];
88
+ return ja < jb ? -1 : ja > jb ? 1 : 0;
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Where a declared `path` source lives on disk: absolute as written, otherwise
94
+ * relative to the DECLARING config's directory — never to ambient
95
+ * `process.cwd()`.
96
+ *
97
+ * One definition, because this expression *is* the rule for where a declared
98
+ * source lives, which is the single piece of knowledge this module exists to
99
+ * own. A caller that needs a source's root directory (rather than its files)
100
+ * calls this rather than restating it.
101
+ */
102
+ export function resolveSpecPath(configDir: string, spec: { readonly path: string }): string {
103
+ return isAbsolute(spec.path) ? spec.path : resolve(configDir, spec.path);
104
+ }
105
+
106
+ /**
107
+ * Resolve a declared source SET to a canonically-ordered list of metadata files.
108
+ *
109
+ * The full result — each entry's `.file` AND its `.spec` — is a pure function
110
+ * of the SET of `specs`: permuting `specs` cannot change the output. One thing
111
+ * makes that hold: the specs are processed in CONTENT order
112
+ * (`JSON.stringify(spec)`, ascending) rather than declared order, so both the
113
+ * emitted file order and the spec attributed to a file overlapping two specs
114
+ * are decided by content alone. Declared order carries no information anywhere
115
+ * in this function.
116
+ *
117
+ * Within one directory spec the file order is `listMetadataFiles`'s — files at
118
+ * a level, then that level's subdirectories, depth-first. That is deliberately
119
+ * NOT a flat sort of absolute paths: see the file header, and
120
+ * `test/source-order.test.ts`.
121
+ *
122
+ * Only `path` specs resolve in phase 1: a directory is walked recursively, a
123
+ * file is taken as-is. An unresolvable `path` throws `ERR_SOURCE_UNRESOLVED`
124
+ * rather than silently contributing nothing; `resource`/`package` specs throw
125
+ * `ERR_SOURCE_KIND_UNSUPPORTED`.
126
+ *
127
+ * @param configDir absolute directory of the declaring config (the parent of
128
+ * `.metaobjects/`) — relative `path` specs resolve against it, never against
129
+ * ambient `process.cwd()`.
130
+ */
131
+ export async function resolveSources(
132
+ configDir: string,
133
+ specs: readonly SourceSpec[],
134
+ ): Promise<ResolvedSource[]> {
135
+ // Kind-validated and content-ordered in one pass — see `orderedPathSpecs`.
136
+ const ordered = orderedPathSpecs(specs);
137
+
138
+ // Insertion order IS output order — a Map preserves it, so the per-spec walk
139
+ // order above survives to the caller. First contributor wins a shared file,
140
+ // which is content-determined because `ordered` is.
141
+ const byFile = new Map<string, SourceSpec>();
142
+
143
+ for (const spec of ordered) {
144
+ const target = resolveSpecPath(configDir, spec);
145
+ const stats = await stat(target).catch(() => undefined);
146
+ if (stats === undefined) {
147
+ throw new ParseError(
148
+ `source path "${spec.path}" does not exist (resolved to ${target}, relative to ${configDir})`,
149
+ { code: "ERR_SOURCE_UNRESOLVED", source: codeSource("resolveSources") },
150
+ );
151
+ }
152
+
153
+ const found = stats.isDirectory() ? await listMetadataFiles(target) : [target];
154
+ for (const file of found) {
155
+ if (!byFile.has(file)) byFile.set(file, spec);
156
+ }
157
+ }
158
+
159
+ return [...byFile].map(([file, spec]) => ({ file, spec }));
160
+ }