@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
@@ -0,0 +1,183 @@
1
+ // server/typescript/packages/sdk/src/collection.ts
2
+ //
3
+ // Phase-1 metadata-source-resolution — the single authority.
4
+ //
5
+ // `resolveCollection()` composes discovery (`discovery.ts`), config
6
+ // (`config.ts`), source resolution (`sources.ts`) and the scope engine
7
+ // (`scope.ts`) into one function that decides where a project's metadata
8
+ // lives. `metaobjects/` is the DEFAULT value of `sources`, never a
9
+ // requirement — a project that declares nothing still resolves exactly as
10
+ // today (`DEFAULT_SOURCES` in `sources.ts`); a project that declares
11
+ // `sources` can point anywhere. No other call site may assume the directory
12
+ // name — this is where that assumption is allowed to live, exactly once.
13
+ import { join, resolve } from "node:path";
14
+ import { ParseError, codeSource } from "@metaobjectsdev/metadata";
15
+ import { CONFIG_FILE, loadConfig, type Config } from "./config.js";
16
+ import { discoverCollectionRoot, exists, isDir } from "./discovery.js";
17
+ import { compileScope, matchesScope, type Scope } from "./scope.js";
18
+ import { DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR } from "./metadata-files.js";
19
+ import {
20
+ DEFAULT_SOURCES,
21
+ orderedPathSpecs,
22
+ resolveSpecPath,
23
+ resolveSources,
24
+ type ResolvedSource,
25
+ type SourceSpec,
26
+ } from "./sources.js";
27
+
28
+ export interface Collection {
29
+ /** Directory whose config declared this collection (or the resolved start
30
+ * directory, when nothing was discovered and the default applies). */
31
+ readonly configDir: string;
32
+ /** Canonically-ordered absolute metadata file paths — see `resolveSources`.
33
+ * Canonical, not sorted: within a directory source the walk order the
34
+ * toolchain has always used is preserved, because it survives into
35
+ * generated output. */
36
+ readonly files: readonly string[];
37
+ /** Same set, carrying the contributing spec for provenance. */
38
+ readonly sources: readonly ResolvedSource[];
39
+ /** The distinct roots the declared source specs resolve to, absolute, in the
40
+ * same canonical (content) order `files` uses. Derived from the DECLARED
41
+ * specs, not from the resolved files, so a source directory that legitimately
42
+ * holds no metadata still appears — a consumer listing "where this model
43
+ * comes from" (`meta docs --site` groups its pages by source root) must not
44
+ * silently lose a declared source because it happens to be empty today. */
45
+ readonly sourceRoots: readonly string[];
46
+ /**
47
+ * Output filter for codegen: does this fully-qualified name survive the
48
+ * collection's `scope`? Always defined — an unconfigured project compiles to
49
+ * an empty include/exclude, which admits everything, so callers pass this
50
+ * through unconditionally rather than branching.
51
+ *
52
+ * A PREDICATE rather than the `CompiledScope` it closes over, because nothing
53
+ * consumes a compiled scope as a compiled scope: every consumer immediately
54
+ * wrapped it in exactly this lambda, and `migrateScopePatterns` exists
55
+ * precisely because the compiled form cannot be shown to a human.
56
+ * `compileScope`/`matchesScope` stay exported for the conformance corpus.
57
+ */
58
+ readonly inScope: (fqn: string) => boolean;
59
+ /** Output filter for migrate/verify --db (`migrate.scope`). Undefined => the
60
+ * command governs everything loaded, and that undefined is load-bearing: it
61
+ * is what leaves the expected schema untouched (migrate-ts `scope.ts`). */
62
+ readonly inMigrateScope: ((fqn: string) => boolean) | undefined;
63
+ /** The patterns `inMigrateScope` was compiled FROM, for diagnostics only —
64
+ * `compileScope` produces RegExps, and a regex source is not something to
65
+ * show an author who wrote `acme::platform::**`. Carried so the "your scope
66
+ * matched nothing" refusal can name the patterns that missed. Always in
67
+ * lockstep with `inMigrateScope`: both undefined, or both present. */
68
+ readonly migrateScopePatterns: readonly string[] | undefined;
69
+ }
70
+
71
+ /** Narrow the zod-inferred `Config["scope"]` (whose `.optional()` fields are
72
+ * typed `T | undefined` even when present) down to `Scope`'s
73
+ * exactOptionalPropertyTypes-safe shape — a key is omitted entirely rather
74
+ * than assigned `undefined`. */
75
+ function toScope(spec: Config["scope"]): Scope {
76
+ return {
77
+ ...(spec?.include !== undefined && { include: spec.include }),
78
+ ...(spec?.exclude !== undefined && { exclude: spec.exclude }),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * THE single authority on where metadata lives. Every read path routes
84
+ * through this — `metaobjects/` is the DEFAULT value of `sources`, never an
85
+ * assumption baked into a call site.
86
+ *
87
+ * Resolution order: an explicit `opts.explicitDir` wins outright; otherwise
88
+ * `discoverCollectionRoot` walks up from `startDir` for the nearest directory
89
+ * carrying `.metaobjects/config.json` — the ONLY project marker (`discovery.ts`
90
+ * says why a directory that merely holds metadata is not one) — falling back to
91
+ * `startDir` itself when none is found. When the resolved directory carries a
92
+ * config, its declared `sources`/`scope`/`migrate.scope` govern. Only a
93
+ * genuinely ABSENT `config.json` falls through to `DEFAULT_SOURCES` — the same
94
+ * directory the pre-source-resolution toolchain always read; a config.json
95
+ * that EXISTS but fails to load (malformed JSON, schema violation) is the
96
+ * author's error and propagates rather than silently degrading — a source
97
+ * that fails to resolve must never look like one that was never declared.
98
+ * Throws `ERR_COLLECTION_NOT_FOUND` only when BOTH have failed: no
99
+ * `sources` were declared AND the default source directory does not exist
100
+ * either.
101
+ *
102
+ * A declared source that fails to resolve is a different, louder failure —
103
+ * `resolveSources` throws `ERR_SOURCE_UNRESOLVED` for that case; only the
104
+ * DEFAULT is allowed to be silently absent.
105
+ */
106
+ export async function resolveCollection(
107
+ startDir: string,
108
+ opts?: { explicitDir?: string },
109
+ ): Promise<Collection> {
110
+ const explicit = opts?.explicitDir;
111
+
112
+ // Whether `configDir` carries a `config.json` — threaded through rather
113
+ // than re-`stat`'d below. On the non-explicit path, `discoverCollectionRoot`
114
+ // already proved it either way: it reports `hasConfig` from the same
115
+ // `.metaobjects/config.json` probe that decided where to stop, and reports
116
+ // false only after confirming that file is absent at every directory it
117
+ // examined, `resolve(startDir)` included. A second `stat` of the identical
118
+ // file would just re-prove what discovery established. The check is only
119
+ // load-bearing on the `explicitDir` path, where discovery never runs at all.
120
+ let configDir: string;
121
+ let hasConfig: boolean;
122
+ if (explicit !== undefined) {
123
+ configDir = resolve(explicit);
124
+ hasConfig = await exists(join(configDir, DEFAULT_METAOBJECTS_DIR, CONFIG_FILE));
125
+ } else {
126
+ ({ dir: configDir, hasConfig } = await discoverCollectionRoot(startDir));
127
+ }
128
+
129
+ let specs: readonly SourceSpec[] = DEFAULT_SOURCES;
130
+ let scopeSpec: Config["scope"];
131
+ let migrateSpec: string[] | undefined;
132
+
133
+ if (hasConfig) {
134
+ // No try/catch here: a config.json that EXISTS but fails to load
135
+ // (malformed JSON, a ConfigSchema violation) propagates. Swallowing it
136
+ // would make a typo'd config behave identically to no config at all —
137
+ // silently generating from a possibly-stale `metaobjects/` with no
138
+ // diagnostic, which is a worse failure than the one this design exists
139
+ // to remove.
140
+ const cfg = await loadConfig(join(configDir, DEFAULT_METAOBJECTS_DIR));
141
+ if (cfg.sources.length > 0) specs = cfg.sources;
142
+ scopeSpec = cfg.scope;
143
+ migrateSpec = cfg.migrate?.scope;
144
+ }
145
+
146
+ // Only the DEFAULT is allowed to be absent — an explicitly declared source
147
+ // that does not resolve is `resolveSources`'s ERR_SOURCE_UNRESOLVED, not this.
148
+ //
149
+ // This re-`stat`s a directory the non-explicit discovery walk may already
150
+ // have probed, and that redundancy is intentional: it exists to produce the
151
+ // friendlier `ERR_COLLECTION_NOT_FOUND` diagnostic below rather than the raw
152
+ // `ERR_SOURCE_UNRESOLVED` `resolveSources` would throw on a genuinely missing
153
+ // default directory. Trading that clearer error for one syscall is a bad
154
+ // trade. It is also load-bearing outright on the `explicitDir` path and
155
+ // whenever a discovered config declares no `sources`, where nothing has
156
+ // probed it at all.
157
+ if (specs === DEFAULT_SOURCES && !(await isDir(join(configDir, DEFAULT_METADATA_DIR)))) {
158
+ throw new ParseError(
159
+ `no metadata sources declared in ${configDir} and no default "${DEFAULT_METADATA_DIR}" directory found. ` +
160
+ `Declare "sources" in ${DEFAULT_METAOBJECTS_DIR}/config.json, or run 'meta init' to scaffold.`,
161
+ { code: "ERR_COLLECTION_NOT_FOUND", source: codeSource("resolveCollection") },
162
+ );
163
+ }
164
+
165
+ const sources = await resolveSources(configDir, specs);
166
+ const scope = compileScope(toScope(scopeSpec));
167
+ const migrateScope =
168
+ migrateSpec === undefined ? undefined : compileScope({ include: migrateSpec });
169
+ return {
170
+ configDir,
171
+ files: sources.map((s) => s.file),
172
+ sources,
173
+ // Canonical (content) order, from `resolveSources`'s own ordering — so this
174
+ // list is a pure function of the source SET, exactly like `files`.
175
+ sourceRoots: [
176
+ ...new Set(orderedPathSpecs(specs).map((spec) => resolveSpecPath(configDir, spec))),
177
+ ],
178
+ inScope: (fqn: string): boolean => matchesScope(fqn, scope),
179
+ inMigrateScope:
180
+ migrateScope === undefined ? undefined : (fqn: string): boolean => matchesScope(fqn, migrateScope),
181
+ migrateScopePatterns: migrateSpec,
182
+ };
183
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import type { SourceSpec } from "./sources.js";
4
5
 
5
6
  const DialectEnum = z.enum(["sqlite", "postgres", "d1"]);
6
7
 
@@ -30,14 +31,18 @@ export const AllowTokenEnum = z.enum([
30
31
  "adopt-view",
31
32
  "nullable-to-not-null",
32
33
  "drop-identity-default",
34
+ "drop-unmanaged",
33
35
  ]);
34
36
 
37
+ // .strict(), like every other object in this schema: `.partial()` alone leaves
38
+ // zod's default STRIP policy in place, so a misspelled key is silently deleted
39
+ // and the block reads as if the author never wrote it.
35
40
  const D1Block = z.object({
36
41
  binding: z.string(),
37
42
  remote: z.boolean(),
38
43
  autoApply: z.boolean(),
39
44
  wranglerConfigPath: z.string(),
40
- }).partial();
45
+ }).partial().strict();
41
46
 
42
47
  /** #192 — migration output-format adapters; orthogonal to dialect. */
43
48
  const MigrateFormatEnum = z.enum(["default", "flyway"]);
@@ -50,8 +55,71 @@ const MigrateBlock = z.object({
50
55
  onAmbiguous: OnAmbiguousEnum,
51
56
  allow: z.array(AllowTokenEnum),
52
57
  d1: D1Block,
53
- }).partial();
58
+ /** Restricts a `meta migrate` run to a subset of the loaded metadata, by the
59
+ * same package-glob pattern grammar as top-level `scope` (see `scope.ts`).
60
+ * Include-only — there's no `migrate.scope.exclude`, since a migration run
61
+ * is scoped to what it's touching, not filtered down from "everything". */
62
+ scope: z.array(z.string().min(1)),
63
+ // .strict() for the same reason as the top level and the source arms below, and
64
+ // with sharper teeth here: `{ migrate: { scopee: [...], dialect: "postgres" } }`
65
+ // used to parse to `{ dialect: "postgres" }`, so a typo'd `scope` key meant
66
+ // "unscoped" — silently governing every table in the database, which is the
67
+ // hazard `migrate.scope` exists to remove.
68
+ }).partial().strict();
54
69
 
70
+ /**
71
+ * Mirrors the hand-written `SourceSpec` union in `./sources.ts` — a
72
+ * declared source kind, one of `path` (resolves today), `resource`, or
73
+ * `package` (both reserved, throw `ERR_SOURCE_KIND_UNSUPPORTED` until a
74
+ * later phase). `.strict()` on every arm: this project is fail-closed on
75
+ * undeclared keys everywhere else (ADR-0023 makes an unregistered metadata
76
+ * attribute a hard error for the same reason) — a config schema that
77
+ * silently strips an unknown key would let `{ path: "model", pathh: "typo"
78
+ * }` parse clean and resolve one source instead of erroring on the typo.
79
+ * The pre-phase-1 `{ kind: "path", path: "..." }` shape (the dead 2-arm
80
+ * discriminated union this replaces) never shipped to an adopter — `meta
81
+ * init` has only ever scaffolded `"sources": []`, and nothing under `src/`
82
+ * ever read the old shape — so there is no live config to be lenient for;
83
+ * it only ever existed in this package's own tests, updated alongside this
84
+ * schema.
85
+ */
86
+ const SourceSpecSchema = z.union([
87
+ z.object({ path: z.string().min(1) }).strict(),
88
+ z.object({ resource: z.string().min(1) }).strict(),
89
+ z.object({ package: z.string().min(1) }).strict(),
90
+ ]);
91
+
92
+ // Compile-time parity, BOTH directions: if SourceSpecSchema and the
93
+ // hand-written SourceSpec (./sources.ts) ever drift, one of these two
94
+ // assignments stops compiling. Each direction alone catches only HALF the
95
+ // drift — `z.infer<...>` assignable to `SourceSpec` catches an arm added to
96
+ // the schema but missing from SourceSpec, while `SourceSpec` assignable to
97
+ // `z.infer<...>` catches the opposite: an arm added to the hand-written
98
+ // SourceSpec that the schema never gained. A single one-directional
99
+ // assignment (the prior form of this guard) let a SourceSpec-only addition
100
+ // compile clean — proven by deliberately breaking each direction in
101
+ // isolation; see the quality-pass report for both failing `tsc` outputs. A
102
+ // conditional type (`X extends Y ? true : never`) would silently resolve to
103
+ // `never` instead of erroring — this direct-assignment form fails for real.
104
+ const _sourceSpecParityInferToSpec: SourceSpec = {} as z.infer<typeof SourceSpecSchema>;
105
+ const _sourceSpecParitySpecToInfer: z.infer<typeof SourceSpecSchema> = {} as SourceSpec;
106
+ void _sourceSpecParityInferToSpec;
107
+ void _sourceSpecParitySpecToInfer;
108
+
109
+ /** Mirrors the hand-written `Scope` interface in `./scope.ts`. An absent or
110
+ * empty `include` means "everything" — see `matchesScope`. */
111
+ const ScopeSchema = z
112
+ .object({
113
+ include: z.array(z.string().min(1)).optional(),
114
+ exclude: z.array(z.string().min(1)).optional(),
115
+ })
116
+ .strict();
117
+
118
+ // .strict() at the TOP level too, not just the source-spec arms and the
119
+ // scope block: without it, a misspelled top-level key (e.g. "scopes" for
120
+ // "scope") is silently stripped by zod and the collection resolves as
121
+ // "everything in scope" — the exact silent fail-open .strict() on the
122
+ // nested arms exists to prevent, one level up.
55
123
  export const ConfigSchema = z.object({
56
124
  schema_version: z.literal(1),
57
125
  pending_in_git: z.boolean().default(true),
@@ -61,27 +129,27 @@ export const ConfigSchema = z.object({
61
129
  drift_warn: z.number().min(0).max(1).default(0.7),
62
130
  })
63
131
  .default({}),
64
- sources: z
65
- .array(
66
- z.union([
67
- z.object({ kind: z.literal("path"), path: z.string() }),
68
- z.object({ kind: z.literal("package"), package: z.string() }),
69
- ]),
70
- )
71
- .default([]),
132
+ sources: z.array(SourceSpecSchema).default([]),
133
+ /** Output filter applied across every command — see `./scope.ts`. Absent
134
+ * means "everything" (no filtering), matching `Scope`'s own contract. */
135
+ scope: ScopeSchema.optional(),
72
136
  extract: z
73
137
  .object({
74
138
  metaignore: z.string().optional(),
75
139
  })
76
140
  .default({}),
77
141
  migrate: MigrateBlock.optional(),
78
- });
142
+ }).strict();
79
143
 
80
144
  export type Config = z.infer<typeof ConfigSchema>;
81
145
 
82
146
  export const DEFAULT_CONFIG: Config = ConfigSchema.parse({ schema_version: 1 });
83
147
 
84
- const CONFIG_FILE = "config.json";
148
+ /** `config.json`'s basename — the single owner. `discovery.ts` and
149
+ * `collection.ts` import this rather than each keeping their own copy of
150
+ * the literal (a duplication that had drifted into three separate
151
+ * declarations of the same string). */
152
+ export const CONFIG_FILE = "config.json";
85
153
 
86
154
  export async function loadConfig(metaRoot: string): Promise<Config> {
87
155
  const raw = await readFile(join(metaRoot, CONFIG_FILE), "utf8");
@@ -0,0 +1,110 @@
1
+ // server/typescript/packages/sdk/src/discovery.ts
2
+ //
3
+ // Phase-1 metadata-source-resolution — nearest-ancestor collection discovery.
4
+ //
5
+ // Walks up from a starting directory to find the nearest directory that IS a
6
+ // project root. This is what makes a CLI *contextual*: run it inside an app in
7
+ // a monorepo and it finds that app's configuration rather than the repo root's.
8
+ //
9
+ // Three properties are load-bearing.
10
+ //
11
+ // 1. **One marker.** A directory is a project root when it carries
12
+ // `.metaobjects/config.json`, and on no other evidence. A directory that
13
+ // merely *holds* metadata is not a project boundary: where metadata lives is
14
+ // the `sources` key's answer, and `metaobjects/` is only that key's default
15
+ // value. Stopping on a bare `metaobjects/` directory would put a second
16
+ // definition of "where metadata lives" back into the walk — the exact
17
+ // duplication `resolveCollection` exists to be the only instance of — and it
18
+ // would ignore a project whose config points its `sources` somewhere else
19
+ // entirely. See design §4.6.1.
20
+ // 2. **Nearest wins** — the walk returns on the FIRST directory carrying the
21
+ // marker, so a config in a subdirectory beats one in an ancestor.
22
+ // 3. **The walk stops at a repository boundary** (`.git`), so a monorepo
23
+ // checkout can never silently adopt a *parent checkout's* configuration. The
24
+ // marker check runs BEFORE the `.git` check within each directory —
25
+ // reversed, a root-level project (where `.git` also lives) would be
26
+ // unreachable from any subdirectory, since the boundary would stop the walk
27
+ // one directory too early.
28
+ import { stat } from "node:fs/promises";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { CONFIG_FILE } from "./config.js";
31
+ import { DEFAULT_METAOBJECTS_DIR } from "./metadata-files.js";
32
+
33
+ const GIT_DIR = ".git";
34
+
35
+ /** Exported for reuse — `collection.ts` had its own byte-identical copy
36
+ * (`fileExists`); one definition, imported. */
37
+ export async function exists(path: string): Promise<boolean> {
38
+ try {
39
+ await stat(path);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** `exists`, narrowed to directories — `collection.ts`'s default-source probe
47
+ * needs that distinction to raise its friendlier `ERR_COLLECTION_NOT_FOUND`
48
+ * (a plain FILE where the default source directory should be is not a
49
+ * metadata home). Lives here beside `exists` so there is one filesystem
50
+ * predicate pair in the package rather than a copy per caller. */
51
+ export async function isDir(path: string): Promise<boolean> {
52
+ try {
53
+ return (await stat(path)).isDirectory();
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /** Where the discovery walk stopped, and what it found there. */
60
+ export interface DiscoveredRoot {
61
+ /** The project root the walk settled on. Always absolute; falls back to the
62
+ * resolved start directory when the walk found no marker at all. */
63
+ readonly dir: string;
64
+ /** Whether `dir` carries `.metaobjects/config.json`. False only on the
65
+ * no-marker fallback, where the DEFAULT sources apply. */
66
+ readonly hasConfig: boolean;
67
+ }
68
+
69
+ /**
70
+ * Walk up from `startDir` for the nearest project root — a directory holding
71
+ * `.metaobjects/config.json`, and nothing else (see the file header). The walk
72
+ * stops after examining a directory that contains `.git`, so a monorepo can
73
+ * never silently adopt a parent checkout's configuration.
74
+ *
75
+ * Never fails: with no config anywhere below the boundary it reports the
76
+ * resolved `startDir` with `hasConfig: false`, which is what
77
+ * `resolveCollection` turns into either the default source or
78
+ * `ERR_COLLECTION_NOT_FOUND`.
79
+ */
80
+ export async function discoverCollectionRoot(startDir: string): Promise<DiscoveredRoot> {
81
+ const start = resolve(startDir);
82
+ let dir = start;
83
+ for (;;) {
84
+ if (await exists(join(dir, DEFAULT_METAOBJECTS_DIR, CONFIG_FILE))) {
85
+ return { dir, hasConfig: true };
86
+ }
87
+ // Boundary check AFTER the marker check: a repo-root project (sharing its
88
+ // directory with `.git`) is still findable from any subdirectory.
89
+ if (await exists(join(dir, GIT_DIR))) break;
90
+ const parent = dirname(dir);
91
+ if (parent === dir) break;
92
+ dir = parent;
93
+ }
94
+ return { dir: start, hasConfig: false };
95
+ }
96
+
97
+ /**
98
+ * The directory whose configuration governs a run started in `startDir` — the
99
+ * `dir` half of {@link discoverCollectionRoot}.
100
+ *
101
+ * Exists as its own export for the callers that must NOT require metadata to
102
+ * exist: `meta migrate apply-pending` and `--rollback` replay committed SQL and
103
+ * load no model at all, so they resolve their `.metaobjects/` directory through
104
+ * this rather than through `resolveCollection`. Sharing the walk is the point —
105
+ * a second "find the project root" implementation is how the migrations
106
+ * directory and the metadata directory come to disagree.
107
+ */
108
+ export async function resolveConfigDir(startDir: string): Promise<string> {
109
+ return (await discoverCollectionRoot(startDir)).dir;
110
+ }
package/src/index.ts CHANGED
@@ -76,17 +76,34 @@ export {
76
76
  } from "./forge-types.js";
77
77
  export type { ForgeType, ForgeAttr } from "./forge-types.js";
78
78
 
79
- // Memory loader — read metaobjects/ into a MetaData tree
80
- // (workspace-aware: walks extends: deps via pnpm-workspace.yaml or
81
- // package.json workspaces field if present)
82
- export {
83
- loadMemory,
84
- defaultLoadMemoryProviders,
85
- DEFAULT_METADATA_DIR,
86
- DEFAULT_METAOBJECTS_DIR,
87
- } from "./memory.js";
79
+ // Memory loader — read a project's resolved metadata into a MetaData tree.
80
+ // Where those files come from is `resolveCollection`'s decision (below), which
81
+ // `loadMemory` calls when the caller supplies no explicit file set.
82
+ export { loadMemory, defaultLoadMemoryProviders } from "./memory.js";
88
83
  export type { LoadMemoryOptions } from "./memory.js";
89
84
 
85
+ // Default project layout — the DEFAULT value of `sources` (applied by
86
+ // `resolveCollection` alone) and the fixed directory holding the config that
87
+ // declares them. Exported for `meta init`, which SCAFFOLDS that layout.
88
+ export { DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR } from "./metadata-files.js";
89
+
90
+ // Scope — output filter over fully-qualified node names
91
+ export { compileScope, matchesScope } from "./scope.js";
92
+ export type { Scope, CompiledScope } from "./scope.js";
93
+
94
+ // Source resolution — a declared source SET to a canonically-sorted file list
95
+ export { resolveSources, resolveSpecPath, orderedPathSpecs, DEFAULT_SOURCES } from "./sources.js";
96
+ export type { SourceSpec, ResolvedSource } from "./sources.js";
97
+
98
+ // Discovery — nearest-ancestor project root (a `.metaobjects/config.json`,
99
+ // the only marker), bounded by the repo root
100
+ export { discoverCollectionRoot, resolveConfigDir } from "./discovery.js";
101
+ export type { DiscoveredRoot } from "./discovery.js";
102
+
103
+ // Collection — the single authority on where a project's metadata lives
104
+ export { resolveCollection } from "./collection.js";
105
+ export type { Collection } from "./collection.js";
106
+
90
107
  // Workspace discovery — finds peer metadata packages in a monorepo
91
108
  export { discoverWorkspace, resolveExtendsOrder, packageLabel } from "./workspace.js";
92
109
  export type { Workspace, WorkspacePackage } from "./workspace.js";