@metaobjectsdev/sdk 0.23.2 → 0.24.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 (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 +65 -19
  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 +53 -10
  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 +12 -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 +12 -2
  15. package/agent-context/skills/metaobjects-codegen/references/typescript.md +14 -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 +203 -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 +268 -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,130 @@
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
+ /** Used when `sources` is absent or empty in `.metaobjects/config.json`. A
28
+ * DEFAULT, never a requirement — a project that declares `sources` explicitly
29
+ * need not include the default directory at all. Built from
30
+ * `DEFAULT_METADATA_DIR` (`metadata-files.ts`'s single definition) rather than
31
+ * restating that name here: a second independent encoding of the same
32
+ * default would let `resolveCollection`'s "does the default dir exist"
33
+ * check (`collection.ts`) desync from what `resolveSources` actually
34
+ * resolves the moment the default ever changed — silently reproducing the
35
+ * "two code paths disagree about where metadata lives" class of bug this
36
+ * whole mechanism exists to eliminate. */
37
+ export const DEFAULT_SOURCES = [{ path: DEFAULT_METADATA_DIR }];
38
+ /** Narrows `spec` to its `path` arm, throwing `ERR_SOURCE_KIND_UNSUPPORTED`
39
+ * for `resource`/`package` — phase 1 resolves `path` only. Returns rather than
40
+ * asserting so one call both validates and narrows: an `asserts` signature has
41
+ * to be re-invoked wherever TypeScript's control-flow analysis cannot carry the
42
+ * narrowing, which is a language workaround masquerading as a second check. */
43
+ function toPathSpec(spec) {
44
+ if ("path" in spec)
45
+ return spec;
46
+ const kind = "resource" in spec ? "resource" : "package";
47
+ throw new ParseError(`source kind "${kind}" is not supported by this toolchain yet; use a "path" source`, { code: "ERR_SOURCE_KIND_UNSUPPORTED", source: codeSource("resolveSources") });
48
+ }
49
+ /**
50
+ * The declared source SET in CANONICAL order — kind-validated, then sorted by
51
+ * spec CONTENT rather than by declaration order.
52
+ *
53
+ * This is the ONE place declaration order is discarded, and the module's "pure
54
+ * function of the SET" invariant rests on it: the emitted file order, the spec
55
+ * attributed to a file two specs both reach, and which of several unresolvable
56
+ * paths reports its `ERR_SOURCE_UNRESOLVED` first are all decided here.
57
+ * Validation runs across the WHOLE list before any sorting or filesystem I/O —
58
+ * interleaved with resolution, which error code came back would depend on
59
+ * declaration order, contradicting that same invariant.
60
+ *
61
+ * Exported because `resolveCollection` derives `sourceRoots` from the declared
62
+ * specs and must use this identical ordering; a second sort would be a second
63
+ * definition of "canonical".
64
+ */
65
+ export function orderedPathSpecs(specs) {
66
+ return specs.map(toPathSpec).sort((a, b) => {
67
+ const [ja, jb] = [JSON.stringify(a), JSON.stringify(b)];
68
+ return ja < jb ? -1 : ja > jb ? 1 : 0;
69
+ });
70
+ }
71
+ /**
72
+ * Where a declared `path` source lives on disk: absolute as written, otherwise
73
+ * relative to the DECLARING config's directory — never to ambient
74
+ * `process.cwd()`.
75
+ *
76
+ * One definition, because this expression *is* the rule for where a declared
77
+ * source lives, which is the single piece of knowledge this module exists to
78
+ * own. A caller that needs a source's root directory (rather than its files)
79
+ * calls this rather than restating it.
80
+ */
81
+ export function resolveSpecPath(configDir, spec) {
82
+ return isAbsolute(spec.path) ? spec.path : resolve(configDir, spec.path);
83
+ }
84
+ /**
85
+ * Resolve a declared source SET to a canonically-ordered list of metadata files.
86
+ *
87
+ * The full result — each entry's `.file` AND its `.spec` — is a pure function
88
+ * of the SET of `specs`: permuting `specs` cannot change the output. One thing
89
+ * makes that hold: the specs are processed in CONTENT order
90
+ * (`JSON.stringify(spec)`, ascending) rather than declared order, so both the
91
+ * emitted file order and the spec attributed to a file overlapping two specs
92
+ * are decided by content alone. Declared order carries no information anywhere
93
+ * in this function.
94
+ *
95
+ * Within one directory spec the file order is `listMetadataFiles`'s — files at
96
+ * a level, then that level's subdirectories, depth-first. That is deliberately
97
+ * NOT a flat sort of absolute paths: see the file header, and
98
+ * `test/source-order.test.ts`.
99
+ *
100
+ * Only `path` specs resolve in phase 1: a directory is walked recursively, a
101
+ * file is taken as-is. An unresolvable `path` throws `ERR_SOURCE_UNRESOLVED`
102
+ * rather than silently contributing nothing; `resource`/`package` specs throw
103
+ * `ERR_SOURCE_KIND_UNSUPPORTED`.
104
+ *
105
+ * @param configDir absolute directory of the declaring config (the parent of
106
+ * `.metaobjects/`) — relative `path` specs resolve against it, never against
107
+ * ambient `process.cwd()`.
108
+ */
109
+ export async function resolveSources(configDir, specs) {
110
+ // Kind-validated and content-ordered in one pass — see `orderedPathSpecs`.
111
+ const ordered = orderedPathSpecs(specs);
112
+ // Insertion order IS output order — a Map preserves it, so the per-spec walk
113
+ // order above survives to the caller. First contributor wins a shared file,
114
+ // which is content-determined because `ordered` is.
115
+ const byFile = new Map();
116
+ for (const spec of ordered) {
117
+ const target = resolveSpecPath(configDir, spec);
118
+ const stats = await stat(target).catch(() => undefined);
119
+ if (stats === undefined) {
120
+ throw new ParseError(`source path "${spec.path}" does not exist (resolved to ${target}, relative to ${configDir})`, { code: "ERR_SOURCE_UNRESOLVED", source: codeSource("resolveSources") });
121
+ }
122
+ const found = stats.isDirectory() ? await listMetadataFiles(target) : [target];
123
+ for (const file of found) {
124
+ if (!byFile.has(file))
125
+ byFile.set(file, spec);
126
+ }
127
+ }
128
+ return [...byFile].map(([file, spec]) => ({ file, spec }));
129
+ }
130
+ //# sourceMappingURL=sources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sources.js","sourceRoot":"","sources":["../src/sources.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/sdk",
3
- "version": "0.23.2",
3
+ "version": "0.24.1",
4
4
  "description": "Workspace helpers and agent-docs utilities for MetaObjects projects.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -56,7 +56,7 @@
56
56
  "access": "public"
57
57
  },
58
58
  "dependencies": {
59
- "@metaobjectsdev/metadata": "0.23.2",
59
+ "@metaobjectsdev/metadata": "0.24.1",
60
60
  "zod": "^3.23.0"
61
61
  },
62
62
  "devDependencies": {
@@ -222,7 +222,7 @@ export default defineConfig({
222
222
 
223
223
  Filters live on the generator entry: \`routesFile({ filter: e => e.name !== "AuditLog" })\`
224
224
 
225
- \`.metaobjects/config.json\` is unchanged — it still holds static project state (schema_version, pending_in_git, confidence_thresholds). Generator wiring belongs in \`metaobjects.config.ts\` so TypeScript can type-check the imports.
225
+ \`.metaobjects/config.json\` holds static project state (schema_version, pending_in_git, confidence_thresholds) **and \`sources\` — the set of places your metadata comes from** (see "Where metadata comes from" below). Generator wiring belongs in \`metaobjects.config.ts\` so TypeScript can type-check the imports.
226
226
 
227
227
  ## Generated hooks + grids (TanStack)
228
228
 
@@ -503,13 +503,35 @@ metaobjects/
503
503
  └── _pending/<pkg>.json proposed packages awaiting review
504
504
 
505
505
  .metaobjects/
506
- ├── config.json static project state
506
+ ├── config.json static project state + \`sources\`
507
507
  ├── migrations/ written by meta migrate
508
508
  └── .gen-state/ codegen merge base (gitignored)
509
509
 
510
510
  metaobjects.config.ts generator wiring (committed)
511
511
  \`\`\`
512
512
 
513
+ ### Where metadata comes from — the \`sources\` set
514
+
515
+ **\`metaobjects/\` is the DEFAULT VALUE of \`sources\`, not a requirement.** When \`sources\` is absent or empty in \`.metaobjects/config.json\`, it takes that default — the \`metaobjects/\` directory beside the \`.metaobjects/\` folder holding the config. \`meta init\` scaffolds \`"sources": []\`, so a project that does nothing takes the default.
516
+
517
+ Do NOT assume that directory exists. A project may declare \`sources\` and put its metadata anywhere — a sibling module, a shared model repository, a single file — and need not have a directory of that name at all:
518
+
519
+ \`\`\`json
520
+ {
521
+ "schema_version": 1,
522
+ "sources": [
523
+ { "path": "../model/src/main/resources/metadata" },
524
+ { "path": "metaobjects" }
525
+ ]
526
+ }
527
+ \`\`\`
528
+
529
+ \`meta gen\`, \`meta migrate\`, \`meta verify\`, \`meta docs\` and \`meta export\` all read exactly that set, so pointing \`sources\` elsewhere moves every command together. A \`path\` is read **in place and never installed** or copied.
530
+
531
+ \`sources\` is read by **all four CLI surfaces** — the Node \`meta\` CLI, \`dotnet meta\` (C#), \`metaobjects\` (Python) and \`metaobjects:generate\` (Java and Kotlin, via Maven). Each resolves the same files from the same declaration.
532
+
533
+ **\`sources\` is a set, not an ordered list** — resolution is order-independent, so declaration order never changes what loads. \`config.json\` also rejects unknown top-level keys, so a misspelled key is an error rather than a silently ignored one.
534
+
513
535
  ## Worked example
514
536
 
515
537
  \`\`\`json
@@ -0,0 +1,268 @@
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 { dirname, extname, join, resolve } from "node:path";
14
+ import { readdir, readFile } from "node:fs/promises";
15
+ import { ParseError, codeSource, SUBTYPE_ROOT, TYPE_METADATA } from "@metaobjectsdev/metadata";
16
+ import { CONFIG_FILE, loadConfig, type Config } from "./config.js";
17
+ import { discoverCollectionRoot, exists, isDir } from "./discovery.js";
18
+ import { compileScope, matchesScope, type Scope } from "./scope.js";
19
+ import { DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR, isMetadataFile } from "./metadata-files.js";
20
+ import {
21
+ DEFAULT_SOURCES,
22
+ orderedPathSpecs,
23
+ resolveSpecPath,
24
+ resolveSources,
25
+ type ResolvedSource,
26
+ type SourceSpec,
27
+ } from "./sources.js";
28
+
29
+ export interface Collection {
30
+ /** Directory whose config declared this collection (or the resolved start
31
+ * directory, when nothing was discovered and the default applies). */
32
+ readonly configDir: string;
33
+ /** Canonically-ordered absolute metadata file paths — see `resolveSources`.
34
+ * Canonical, not sorted: within a directory source the walk order the
35
+ * toolchain has always used is preserved, because it survives into
36
+ * generated output. */
37
+ readonly files: readonly string[];
38
+ /** Same set, carrying the contributing spec for provenance. */
39
+ readonly sources: readonly ResolvedSource[];
40
+ /** The distinct roots the declared source specs resolve to, absolute, in the
41
+ * same canonical (content) order `files` uses. Derived from the DECLARED
42
+ * specs, not from the resolved files, so a source directory that legitimately
43
+ * holds no metadata still appears — a consumer listing "where this model
44
+ * comes from" (`meta docs --site` groups its pages by source root) must not
45
+ * silently lose a declared source because it happens to be empty today. */
46
+ readonly sourceRoots: readonly string[];
47
+ /**
48
+ * Output filter for codegen: does this fully-qualified name survive the
49
+ * collection's `scope`? Always defined — an unconfigured project compiles to
50
+ * an empty include/exclude, which admits everything, so callers pass this
51
+ * through unconditionally rather than branching.
52
+ *
53
+ * A PREDICATE rather than the `CompiledScope` it closes over, because nothing
54
+ * consumes a compiled scope as a compiled scope: every consumer immediately
55
+ * wrapped it in exactly this lambda, and `migrateScopePatterns` exists
56
+ * precisely because the compiled form cannot be shown to a human.
57
+ * `compileScope`/`matchesScope` stay exported for the conformance corpus.
58
+ */
59
+ readonly inScope: (fqn: string) => boolean;
60
+ /** Output filter for migrate/verify --db (`migrate.scope`). Undefined => the
61
+ * command governs everything loaded, and that undefined is load-bearing: it
62
+ * is what leaves the expected schema untouched (migrate-ts `scope.ts`). */
63
+ readonly inMigrateScope: ((fqn: string) => boolean) | undefined;
64
+ /** The patterns `inMigrateScope` was compiled FROM, for diagnostics only —
65
+ * `compileScope` produces RegExps, and a regex source is not something to
66
+ * show an author who wrote `acme::platform::**`. Carried so the "your scope
67
+ * matched nothing" refusal can name the patterns that missed. Always in
68
+ * lockstep with `inMigrateScope`: both undefined, or both present. */
69
+ readonly migrateScopePatterns: readonly string[] | undefined;
70
+ }
71
+
72
+ /** The canonical-JSON document root key (`metadata.root`) and the sigil-free
73
+ * YAML root mapping key (`metadata:`, at column 0 — ADR-0006), built from the
74
+ * metamodel constants rather than spelled out. */
75
+ const JSON_ROOT_KEY = `${TYPE_METADATA}.${SUBTYPE_ROOT}`;
76
+ const YAML_ROOT_KEY = new RegExp(`^${TYPE_METADATA}\\s*:`, "m");
77
+
78
+ /** Bound on the sniff below: one hit is enough to diagnose, and this runs only
79
+ * on a path that has already failed. */
80
+ const MAX_SNIFFED_FILES = 20;
81
+
82
+ /**
83
+ * Cheap "is this file a metadata DOCUMENT?" sniff — for a diagnostic, never for
84
+ * loading. It decides whether to change an error MESSAGE; nothing downstream
85
+ * reads its answer, and a false negative costs only the generic message.
86
+ *
87
+ * A file-EXTENSION test cannot answer this: `package.json` and `tsconfig.json`
88
+ * carry a recognized metadata extension ({@link isMetadataFile}), so every JS
89
+ * project root that has no metadata yet would be misdiagnosed as a metadata
90
+ * directory — a confidently wrong hint, which is worse than the generic one it
91
+ * would replace. The document root is `metadata.root` in canonical JSON and the
92
+ * sigil-free `metadata:` key in YAML authoring (ADR-0006), so those are what
93
+ * this looks for.
94
+ */
95
+ async function sniffsAsMetadataDocument(file: string): Promise<boolean> {
96
+ const text = await readFile(file, "utf8").catch(() => undefined);
97
+ if (text === undefined) return false;
98
+ if (extname(file).toLowerCase() !== ".json") {
99
+ // YAML: the root mapping key, at column 0. Not a parse — this package has
100
+ // no YAML parser, and acquiring one to improve an error message would be a
101
+ // dependency bought with nothing.
102
+ return YAML_ROOT_KEY.test(text);
103
+ }
104
+ try {
105
+ const doc: unknown = JSON.parse(text);
106
+ return typeof doc === "object" && doc !== null && JSON_ROOT_KEY in doc;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Does `dir` hold metadata but carry no project marker — i.e. is it the
114
+ * METADATA directory, handed to a command whose directory argument is the
115
+ * PROJECT ROOT? (#344)
116
+ *
117
+ * That mistake is predictable rather than careless: the sibling `docs`
118
+ * positionals in the Python and C# ports ARE the metadata directory, and all
119
+ * three are spelled the same way. The generic diagnostic then advises declaring
120
+ * `sources`, which from inside the metadata directory is a dead end.
121
+ *
122
+ * Only the top level is scanned. A metadata directory has metadata files in it;
123
+ * recursing would walk `node_modules/` on the very input this must NOT
124
+ * misdiagnose.
125
+ */
126
+ async function holdsMetadataButIsNoRoot(dir: string, hasConfig: boolean): Promise<boolean> {
127
+ // A directory carrying the marker IS a project root, by the only definition
128
+ // of one there is (`discovery.ts`). Nothing to diagnose.
129
+ if (hasConfig) return false;
130
+ const entries = await readdir(dir).catch(() => [] as string[]);
131
+ for (const entry of entries.filter(isMetadataFile).slice(0, MAX_SNIFFED_FILES)) {
132
+ if (await sniffsAsMetadataDocument(join(dir, entry))) return true;
133
+ }
134
+ return false;
135
+ }
136
+
137
+ /** Narrow the zod-inferred `Config["scope"]` (whose `.optional()` fields are
138
+ * typed `T | undefined` even when present) down to `Scope`'s
139
+ * exactOptionalPropertyTypes-safe shape — a key is omitted entirely rather
140
+ * than assigned `undefined`. */
141
+ function toScope(spec: Config["scope"]): Scope {
142
+ return {
143
+ ...(spec?.include !== undefined && { include: spec.include }),
144
+ ...(spec?.exclude !== undefined && { exclude: spec.exclude }),
145
+ };
146
+ }
147
+
148
+ /**
149
+ * THE single authority on where metadata lives. Every read path routes
150
+ * through this — `metaobjects/` is the DEFAULT value of `sources`, never an
151
+ * assumption baked into a call site.
152
+ *
153
+ * Resolution order: an explicit `opts.explicitDir` wins outright; otherwise
154
+ * `discoverCollectionRoot` walks up from `startDir` for the nearest directory
155
+ * carrying `.metaobjects/config.json` — the ONLY project marker (`discovery.ts`
156
+ * says why a directory that merely holds metadata is not one) — falling back to
157
+ * `startDir` itself when none is found. When the resolved directory carries a
158
+ * config, its declared `sources`/`scope`/`migrate.scope` govern. Only a
159
+ * genuinely ABSENT `config.json` falls through to `DEFAULT_SOURCES` — the same
160
+ * directory the pre-source-resolution toolchain always read; a config.json
161
+ * that EXISTS but fails to load (malformed JSON, schema violation) is the
162
+ * author's error and propagates rather than silently degrading — a source
163
+ * that fails to resolve must never look like one that was never declared.
164
+ * Throws `ERR_COLLECTION_NOT_FOUND` only when BOTH have failed: no
165
+ * `sources` were declared AND the default source directory does not exist
166
+ * either.
167
+ *
168
+ * A declared source that fails to resolve is a different, louder failure —
169
+ * `resolveSources` throws `ERR_SOURCE_UNRESOLVED` for that case; only the
170
+ * DEFAULT is allowed to be silently absent.
171
+ */
172
+ export async function resolveCollection(
173
+ startDir: string,
174
+ opts?: { explicitDir?: string },
175
+ ): Promise<Collection> {
176
+ const explicit = opts?.explicitDir;
177
+
178
+ // Whether `configDir` carries a `config.json` — threaded through rather
179
+ // than re-`stat`'d below. On the non-explicit path, `discoverCollectionRoot`
180
+ // already proved it either way: it reports `hasConfig` from the same
181
+ // `.metaobjects/config.json` probe that decided where to stop, and reports
182
+ // false only after confirming that file is absent at every directory it
183
+ // examined, `resolve(startDir)` included. A second `stat` of the identical
184
+ // file would just re-prove what discovery established. The check is only
185
+ // load-bearing on the `explicitDir` path, where discovery never runs at all.
186
+ let configDir: string;
187
+ let hasConfig: boolean;
188
+ if (explicit !== undefined) {
189
+ configDir = resolve(explicit);
190
+ hasConfig = await exists(join(configDir, DEFAULT_METAOBJECTS_DIR, CONFIG_FILE));
191
+ } else {
192
+ ({ dir: configDir, hasConfig } = await discoverCollectionRoot(startDir));
193
+ }
194
+
195
+ let specs: readonly SourceSpec[] = DEFAULT_SOURCES;
196
+ let scopeSpec: Config["scope"];
197
+ let migrateSpec: string[] | undefined;
198
+
199
+ if (hasConfig) {
200
+ // No try/catch here: a config.json that EXISTS but fails to load
201
+ // (malformed JSON, a ConfigSchema violation) propagates. Swallowing it
202
+ // would make a typo'd config behave identically to no config at all —
203
+ // silently generating from a possibly-stale `metaobjects/` with no
204
+ // diagnostic, which is a worse failure than the one this design exists
205
+ // to remove.
206
+ const cfg = await loadConfig(join(configDir, DEFAULT_METAOBJECTS_DIR));
207
+ if (cfg.sources.length > 0) specs = cfg.sources;
208
+ scopeSpec = cfg.scope;
209
+ migrateSpec = cfg.migrate?.scope;
210
+ }
211
+
212
+ // Only the DEFAULT is allowed to be absent — an explicitly declared source
213
+ // that does not resolve is `resolveSources`'s ERR_SOURCE_UNRESOLVED, not this.
214
+ //
215
+ // This re-`stat`s a directory the non-explicit discovery walk may already
216
+ // have probed, and that redundancy is intentional: it exists to produce the
217
+ // friendlier `ERR_COLLECTION_NOT_FOUND` diagnostic below rather than the raw
218
+ // `ERR_SOURCE_UNRESOLVED` `resolveSources` would throw on a genuinely missing
219
+ // default directory. Trading that clearer error for one syscall is a bad
220
+ // trade. It is also load-bearing outright on the `explicitDir` path and
221
+ // whenever a discovered config declares no `sources`, where nothing has
222
+ // probed it at all.
223
+ if (specs === DEFAULT_SOURCES && !(await isDir(join(configDir, DEFAULT_METADATA_DIR)))) {
224
+ // #344 — name the one wrong answer worth naming before falling back to the
225
+ // generic advice. Told "declare sources" while standing in the metadata
226
+ // directory, an author declares the metadata FILES, and the config schema
227
+ // then rejects a bare string (a `sources` entry is `{ "path": … }`) — two
228
+ // dead ends in a row for a caller whose only mistake was passing the
229
+ // directory the sibling ports' `docs` positional wants.
230
+ if (await holdsMetadataButIsNoRoot(configDir, hasConfig)) {
231
+ const parent = dirname(configDir);
232
+ const near = await discoverCollectionRoot(parent);
233
+ const suggested = near.hasConfig ? near.dir : parent;
234
+ throw new ParseError(
235
+ `${configDir} looks like a metadata directory, not a project root — it holds metadata ` +
236
+ `files but carries no ${DEFAULT_METAOBJECTS_DIR}/${CONFIG_FILE}. A directory argument here is ` +
237
+ `the PROJECT ROOT that CONTAINS your metadata; where the metadata lives is then the root's ` +
238
+ `"sources" (default: the "${DEFAULT_METADATA_DIR}" directory beneath it). Try ${suggested} instead.`,
239
+ { code: "ERR_COLLECTION_NOT_FOUND", source: codeSource("resolveCollection") },
240
+ );
241
+ }
242
+ throw new ParseError(
243
+ `no metadata sources declared in ${configDir} and no default "${DEFAULT_METADATA_DIR}" directory found. ` +
244
+ `Declare "sources" in ${DEFAULT_METAOBJECTS_DIR}/${CONFIG_FILE} — each entry is an OBJECT, ` +
245
+ `e.g. "sources": [{ "path": "model" }] — or run 'meta init' to scaffold.`,
246
+ { code: "ERR_COLLECTION_NOT_FOUND", source: codeSource("resolveCollection") },
247
+ );
248
+ }
249
+
250
+ const sources = await resolveSources(configDir, specs);
251
+ const scope = compileScope(toScope(scopeSpec));
252
+ const migrateScope =
253
+ migrateSpec === undefined ? undefined : compileScope({ include: migrateSpec });
254
+ return {
255
+ configDir,
256
+ files: sources.map((s) => s.file),
257
+ sources,
258
+ // Canonical (content) order, from `resolveSources`'s own ordering — so this
259
+ // list is a pure function of the source SET, exactly like `files`.
260
+ sourceRoots: [
261
+ ...new Set(orderedPathSpecs(specs).map((spec) => resolveSpecPath(configDir, spec))),
262
+ ],
263
+ inScope: (fqn: string): boolean => matchesScope(fqn, scope),
264
+ inMigrateScope:
265
+ migrateScope === undefined ? undefined : (fqn: string): boolean => matchesScope(fqn, migrateScope),
266
+ migrateScopePatterns: migrateSpec,
267
+ };
268
+ }
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");