@metaobjectsdev/sdk 0.24.0 → 0.24.2

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.
@@ -71,10 +71,33 @@ code behind a grep hit; a "duplicate" validator's *divergence* is the finding.
71
71
  design, and a real estate reports most of itself unclaimed on day one). Score the prose
72
72
  it would replace, and prefer ONE concrete claim an author can check over a
73
73
  whole-estate migration.
74
+ **First establish the estate LOADS.** An older ledger fails the load outright, so
75
+ `meta verify` never runs and everything in `references/requirements.md` is unavailable:
76
+ `@violation` and `@verifiedBy` were retired in **0.24.0** (no deprecation shim — ADR-0023
77
+ seals the registry); an index key declaring both `@fields` and `@expr` is refused since
78
+ **0.24.1**; and **0.24.2** turned `@status: abandoned | superseded` into **`retired`**. Run
79
+ **`meta upgrade`** (previews by default; `--apply` writes) before auditing, and audit the
80
+ upgraded tree. It rewrites only what has one correct answer and **refuses the rest, exiting
81
+ non-zero** — a refusal is itself a finding, naming the entries whose disposition nobody
82
+ recorded.
83
+ **Then audit the `retired` entries specifically.** They are where the ledger earns its
84
+ keep — agents proposing a retired capability's rebuild is the one failure this mechanism
85
+ has controlled evidence against — and they are also where it decays quietly. Check that
86
+ each `@statement` reads as a PROHIBITION rather than a diary entry ("X is never done",
87
+ not "we used to do X"), and that `@counterexample` describes the REVIVAL. A retired entry
88
+ whose statement narrates history is invisible to the reader it exists to stop.
74
89
  - [ ] **F. Drift-gate adoption.** Is `meta verify` wired into CI / pre-commit? Which
75
90
  subverbs (`--codegen` / `--templates` / `--db`)? Committed-codegen freshness gate?
76
91
  Advisories heeded? Routine `--no-verify` bypass? Loader `ERR_*` / warnings addressed?
77
92
  Parse the stable `code` field, not message text (ADR-0009).
93
+ **Is any advisory half switched OFF?** A gate can be wired, green, and muted. The
94
+ requirements authoring lint is advisory and mutable — `--no-requirement-lint` or
95
+ `META_NO_REQUIREMENT_LINT=1` silences it while the gate above it still runs and can still
96
+ exit 1, so a green pipeline says nothing about the seven `WARN_REQUIREMENT_*` authoring
97
+ checks. Grep CI config and scripts for both spellings. Muted is not automatically a defect —
98
+ a project that made a deliberate call is fine — but it moves those checks back onto the
99
+ auditor (see `references/requirements.md`), so **say which it is** rather than reporting the
100
+ ledger as lint-clean.
78
101
  **Coverage completeness — "wired" is not "covers it all."** A gate can be present
79
102
  yet blind to a whole artifact class. Confirm each subverb actually covers what the
80
103
  project HAS: (a) **templates** — `verify --templates` on a CLI before the #193 fix
@@ -114,15 +114,17 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove
114
114
 
115
115
  - **`identity.primary`** (`@generation`) — hunt hand-assigned primary keys / ID generation
116
116
  the primary identity's `@generation` strategy models.
117
- - **`identity.secondary`** (`@fields`; physical escapes `@using`/`@expr`/`@where`/`@orders`)a
118
- UNIQUE alternate key (uniqueness is the type — the legacy `@unique` attr was removed from it);
117
+ - **`identity.secondary`** (keys off `@fields` XOR `@expr` — exactly one, never both; physical
118
+ escapes `@using`/`@where`/`@orders`) — a UNIQUE alternate key (uniqueness is the type — the
119
+ legacy `@unique` attr was removed from it, and the same XOR rule as `index.lookup` applies here
120
+ because ADR-0040 puts uniqueness in the TYPE: a secondary identity IS a unique index);
119
121
  hunt hand-rolled unique constraints or raw-SQL partial/functional unique indexes it models.
120
122
  - **`identity.reference`** (`@references`, `@enforce`) — hunt hand-written FK constraints /
121
123
  reference enforcement the reference identity already declares.
122
124
 
123
125
  ## Index — `index.*` (non-unique retrieval)
124
126
 
125
- - **`index.lookup`** (`@fields` required; physical escapes `@using`/`@expr`/`@where`/`@orders`) —
127
+ - **`index.lookup`** (keys off `@fields` XOR `@expr` — exactly one, never both; physical escapes `@using`/`@where`/`@orders`) —
126
128
  a NON-unique retrieval index (uniqueness is what distinguishes it from `identity.secondary`);
127
129
  hunt hand-created lookup / recency indexes (`CREATE INDEX …`) it models.
128
130
 
@@ -238,6 +240,12 @@ subtypes with opposite polarity: `requirement.functional` fails when NOTHING imp
238
240
  recorded as a TODO, or a ticket number in a comment. **ABSENT disposition means UNDECIDED**,
239
241
  which is the point: a gap nobody has ruled on reads differently from one deliberately
240
242
  accepted.
243
+ - **The doc slots behave differently on this node type** — do not audit them by the generic
244
+ rule below. `title` is CHARTERED here (a requirement's `name` is an identifier, so the label
245
+ lives in `title`) and is **rendered** by `meta docs`, heading each entry after its dotted
246
+ path; `summary` is INERT (nothing reads it, and `@statement` is already the required
247
+ one-liner) and `meta verify` warns on one. So an absent `summary` is correct, a populated
248
+ one is the finding, and a populated `title` is correct.
241
249
  - **CALIBRATION — entirely opt-in and warning-only where it counts.** A project declaring no
242
250
  `requirement.*` nodes sees no diagnostics at all, and object coverage ("entities claimed
243
251
  by nothing") ships as a WARNING deliberately — a real estate carrying one requirement will
@@ -9,8 +9,38 @@ effort re-deriving what a green run already proves.
9
9
 
10
10
  ## What verify has already proven (do not re-check by hand)
11
11
 
12
- Links sit at or below the L4 floor, nesting agrees with levels, `@status` values are legal,
13
- and references resolve (with dangling allowed on `planned`, whose nodes do not exist yet).
12
+ A run answers two different questions in two separate sections. Both are already-proven
13
+ ground; neither is worth an auditor's time.
14
+
15
+ **The gate** settles referential integrity: links sit at or below the L4 floor, nesting
16
+ agrees with levels, `@status` values are legal, and references resolve (with dangling allowed
17
+ on `planned`, whose nodes do not exist yet).
18
+
19
+ **The authoring lint** — printed under its own heading, advisory, and unable to change the
20
+ exit code — settles the naming and prose defects you would otherwise find by reading every
21
+ entry:
22
+
23
+ | code | what a clean run has already proven |
24
+ |---|---|
25
+ | `WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE` | no `name` is blank, space-padded, or carries a `.` / `/` / `:` that breaks the dotted path or the generated stub's filename |
26
+ | `WARN_REQUIREMENT_NAME_READS_AS_PROSE` | no `name` is a sentence sitting in the address slot |
27
+ | `WARN_REQUIREMENT_NAME_RESTATES_STATEMENT` | no `name` and `@statement` are the same sentence written twice |
28
+ | `WARN_REQUIREMENT_PROSE_EMPTY` | no `@statement` / `@counterexample` is declared-but-blank (the loader enforces presence, never content) |
29
+ | `WARN_REQUIREMENT_PROSE_DUPLICATED` | no `description` repeats `@statement` whole or as its opening sentence, and no `@counterexample` repeats it |
30
+ | `WARN_REQUIREMENT_INERT_DOC_SLOT` | no `summary` is set — nothing reads it, and `@statement` is already the required one-liner |
31
+ | `WARN_REQUIREMENT_TITLE_IS_AN_ID` | no `title` leads with a catalogue or ticket id |
32
+
33
+ Two limits, and each puts something back on your list:
34
+
35
+ - **The lint is mutable.** `--no-requirement-lint` / `META_NO_REQUIREMENT_LINT=1` silences the
36
+ advisory half while the gate still runs. **Establish whether the project mutes it** (§F of
37
+ the checklist asks this) — against a muted lint the whole table above proves nothing.
38
+ - **It reports only EXACT repeats.** A paraphrase — `description` restating `@statement` in
39
+ different words, a `@counterexample` that merely negates it — is invisible to the tool and
40
+ is a legitimate hand finding.
41
+
42
+ `title` is deliberately NOT flagged as an inert slot; it is chartered as the entry's label and
43
+ is rendered (see item 6). Do not report a populated `title` as a defect.
14
44
 
15
45
  ## What only a human or an agent reading the code can catch
16
46
 
@@ -40,6 +70,14 @@ functional one.
40
70
  model. If the tree is flat, or every entry is L3, the levels are carrying no information and
41
71
  should be simplified rather than defended.
42
72
 
73
+ **6. Titles that are not labels.** `title` is now **rendered** — `meta docs` heads each entry
74
+ `## checkout.payment — Payment capture`, the path first and the label after it — so a slot
75
+ that used to be inert is read by every reader of the generated page. The lint catches only
76
+ the id-shaped case (`FR-448 …`); it cannot tell a *useless* label from a good one. Read the
77
+ titles: one that restates `@statement`, or repeats the path in prose, is now visible noise in
78
+ a heading rather than a private authoring habit. An absent `title` is NOT a finding — the
79
+ entry heads by its path alone, which is what every sibling surface addresses it by.
80
+
43
81
  ## Scope — do NOT flag these as defects
44
82
 
45
83
  - **Unclaimed `object.value` / `object.projection`.** Exempt by design: a value is a shape, a
@@ -590,17 +590,32 @@ does NOT enforce uniqueness. Choose the right construct by what the constraint I
590
590
  | Unique alternate key (e.g. email, slug) | `identity.secondary` — uniqueness is the type |
591
591
  | Query-performance index, no uniqueness | `index.lookup` |
592
592
 
593
- `@fields` names the indexed columns and is **required** (at least one). The db provider
594
- contributes physical-tuning attrs: `@orders` (per-column sort direction), `@using` (access
595
- method`gin`/`gist`/`hash`; default `btree`), `@expr` (key expression derived from
596
- `@fields`, e.g. for a functional index), and `@where` (partial-index predicate).
593
+ **An index keys off EXACTLY ONE of `@fields` or `@expr`.** `@fields` names the indexed
594
+ columns; `@expr` is a raw key expression used **instead of** `@fields` (a functional index).
595
+ Declaring **neither** or **both** is `ERR_INVALID_INDEX`. This applies to
596
+ `identity.secondary` as well as `index.lookup`: uniqueness lives in the type, so a unique
597
+ index keys itself the same way.
598
+
599
+ Legacy metadata declaring both used to load, with `@fields` **silently discarded**. If you
600
+ meet one, `meta upgrade --apply` drops `@fields` — do not hand-pick the survivor: the index
601
+ in the database is the expression one, so keeping `@expr` reproduces it and keeping
602
+ `@fields` would emit a migration against live data.
603
+
604
+ The db provider contributes physical-tuning attrs alongside either form: `@orders`
605
+ (per-column sort direction), `@using` (access method — `gin`/`gist`/`hash`; default
606
+ `btree`), and `@where` (partial-index predicate).
597
607
 
598
608
  ```json
599
609
  { "index.lookup": { "name": "byCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } }
600
610
  { "index.lookup": { "name": "byStatusCreatedAt", "@fields": ["status", "createdAt"] } }
601
- { "index.lookup": { "name": "byEmailCI", "@fields": ["email"], "@expr": "lower(email)" } }
611
+ { "index.lookup": { "name": "byEmailCI", "@expr": "lower(email)" } }
612
+ { "identity.secondary": { "name": "uniqLowerEmail", "@expr": "lower(email)" } }
602
613
  ```
603
614
 
615
+ > **Do not write `@fields` and `@expr` together.** It reads as "index this column, by this
616
+ > expression", but the expression is the whole key — the `@fields` list was silently
617
+ > discarded. It is now refused rather than half-honoured.
618
+
604
619
  `index.lookup` is a sibling of `identity.*` — declare it as a direct child of an `object.entity`,
605
620
  at the same level as fields and identities.
606
621
 
@@ -828,8 +843,16 @@ amendment 2026-08-06.)
828
843
 
829
844
  **Origin vocabulary (#195).** `origin.aggregate @agg` takes `count`/`sum`/`avg`/`min`/`max`
830
845
  (numeric reduces over `@of`), `any`/`all` (predicate quantifiers over a `@filter`; `@of`
831
- forbidden; empty set → `any=false`, `all=true`), and `collect` (an array rollup of `@of`
832
- into an `isArray` field, with optional `@distinct` / `@orderBy`). Any aggregate may be
846
+ forbidden; empty set → `any=false`, `all=true`), and `collect` (an array rollup
847
+ into an `isArray` field, with optional `@distinct` / `@orderBy`). **`collect` is the one
848
+ `@agg` where `@of` is OPTIONAL (#335):** name a column with `@of` to collect scalars, or
849
+ omit `@of` on a `field.object @objectRef` to collect each related row as that declared
850
+ value object — a **whole-object rollup**, lowered to `jsonb_agg(jsonb_build_object(…))`
851
+ on Postgres. The whole-object form requires an explicit `@via`, refuses `@distinct` (it is
852
+ a no-op whenever the value object carries the primary key), and requires every value-object
853
+ member to match a field on the `@via` **terminal** entity by name, with the same subtype
854
+ and array-ness. The declared value object IS the exposure: a field the entity has and the
855
+ value object omits is not projected. Any aggregate may be
833
856
  row-scoped with `@filter`. `origin.computed` carries a closed structured `@expr` tree (a
834
857
  derived scalar). `origin.first` picks one related row's column (`@of`) along `@via`,
835
858
  ordered by a **required `@orderBy`** (`["field:asc|desc", …]`, with the PK as tie-break) —
@@ -5,13 +5,31 @@ describe. Read the existing requirement nodes before designing anything. Two rul
5
5
  more than the rest.
6
6
 
7
7
  **1. A requirement is PRESCRIPTIVE — it states what should be true, never what happened.**
8
- So when a capability is retired, **delete its requirement** in the same change that removes
9
- the code. `status` is `planned | live | partial`; there is no member meaning "we used to do
10
- this", because every one of those three describes something meant to be true now or soon.
11
-
12
- What a deleted entry leaves behind is a diff, which is the right home for it. If something
13
- about the retirement is worth carrying forward why it went, what replaced it put that in
14
- `notes` on the entry that survives, where a reader looking at today's model will find it.
8
+ `status` is `planned | live | partial | retired`, and every one of them states something
9
+ meant to be true. `retired` is not the exception: it means **"this was built, then
10
+ deliberately removed, and must not be rebuilt"** a prohibition in force, falsifiable by
11
+ exactly one observable, the capability reappearing.
12
+
13
+ So write a retired entry as a rule, not a diary. `statement: "An unpaid order is never
14
+ expired by a wall-clock timer"` is a prohibition; `"We used to expire orders on a timer"` is
15
+ history wearing a requirement's shape, and no gate will catch it. On a retired entry
16
+ `counterexample` describes **the revival** — the thing a future reader is about to propose.
17
+
18
+ **Do not delete a retired capability's entry.** Keeping it is the single most load-bearing
19
+ thing this vocabulary does: given a brief for a capability that had been retired, agents
20
+ working from the model alone proposed rebuilding it 24 times out of 24. The entry is what
21
+ stops that, so a retirement is a status change plus a rewritten statement, never a deletion.
22
+
23
+ Three rules the loader enforces on `retired`:
24
+
25
+ - **`implementedBy` is REFUSED** — a retired capability has no implementation by definition.
26
+ What used to implement it goes in `notes`. If deleting the references feels wrong, the
27
+ capability is probably not retired: an entry whose nodes are still there is `live` or
28
+ `partial`.
29
+ - **`supersededBy` names the requirement that replaced it**, legal here only, and RESOLVED —
30
+ so the chain still works when the replacement is itself retired later.
31
+ - It never counts toward object coverage, and is exempt from the architectural universality
32
+ check.
15
33
 
16
34
  Leaving a dangling `implementedBy` is **correct only on `planned`**: the entry precedes the
17
35
  nodes. On `live` or `partial` the same dangling reference is an error — the model moved and
@@ -27,10 +45,9 @@ rule kills *"the system is reliable"*. If you cannot say what breaking it looks
27
45
  delete it.
28
46
 
29
47
  **Four prose slots, and `statement` is the one that means "description".** A requirement can
30
- also carry the common `title`, `description` and `notes`, and they overlap badly unless you
31
- decide the split before writing any of them:
48
+ also carry the common `description` and `notes`, and they overlap badly unless you decide the
49
+ split before writing any of them:
32
50
 
33
- - `title` — a short **noun-phrase** label (`name` is an identifier; this is what an index shows)
34
51
  - `statement` — **the claim**. This IS the description of what the requirement is
35
52
  - `counterexample` — **what would falsify the claim**, which is what makes it checkable
36
53
  - `description` — **the scope**: what the claim covers, what it deliberately does not, and
@@ -38,12 +55,33 @@ decide the split before writing any of them:
38
55
  - `notes` — **the evidence**: how you know the `status` is true — citations, vocabularies, the
39
56
  control you ran to prove an absence was real
40
57
 
58
+ **`title` is a LABEL and is chartered here; `summary` is not.** A requirement's `name` is an
59
+ identifier and its address renders as a dotted camelCase path, so a short noun-phrase `title` is
60
+ what an index shows — the requirement attribute table in `spec/capability-ledger.md` says so by
61
+ name, and the generated requirements page renders it in the heading after the path
62
+ (`## checkout.payment — Payment capture`). `summary` is different: `statement` is already the
63
+ required one-line sentence, so a `summary` can only repeat it and nothing reads it (`verify` warns,
64
+ `WARN_REQUIREMENT_INERT_DOC_SLOT`). `notes` is unrendered on purpose — chartered internal-only.
65
+
66
+ **Never put a catalogue or ticket id in `title`.** A title is a noun phrase; an id is not a name.
67
+ `title: "FR-467 — Order recording"` is two things in one slot — put the id in `trackedBy` and
68
+ keep the phrase as the title. `verify` warns (`WARN_REQUIREMENT_TITLE_IS_AN_ID`).
69
+
70
+ **The `name` is an address, so write it as an identifier.** It is the segment of the dotted
71
+ path (`Ordering.Placement.Recorded`) and the filename of the generated test stub. A `.` in a
72
+ name is indistinguishable from nesting — `Orders.Recorded` and `Orders` containing `Recorded`
73
+ produce the same path — and a sentence for a name puts the claim somewhere nothing reads it.
74
+ Both load; `verify` warns (`WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE`,
75
+ `WARN_REQUIREMENT_NAME_READS_AS_PROSE`, `WARN_REQUIREMENT_NAME_RESTATES_STATEMENT`).
76
+
41
77
  Two failure modes, both of which look like diligence. A `description` that **paraphrases the
42
78
  statement** is padding, and it makes every later reader trust the ledger less — leave it off
43
79
  instead, it is optional. A `description` that **narrates the evidence** belongs in `notes`;
44
80
  the tell is a fact you had to read the implementation to learn. Mechanical test for the last
45
81
  line: *would this sentence have to change if the code changed but the model did not?* Then it
46
- is `notes`.
82
+ is `notes`. `verify` warns on the exact repeats (`WARN_REQUIREMENT_PROSE_DUPLICATED`), and on
83
+ a `statement` or `counterexample` that is present but blank (`WARN_REQUIREMENT_PROSE_EMPTY`) —
84
+ the loader requires those attrs to exist, never to say anything.
47
85
 
48
86
  **Hierarchy is nesting, and links live at the bottom.** L1 solution, L2 segment, L3
49
87
  service — these never reference the model. **L4** binds a declared top-level node — an
@@ -72,6 +72,16 @@ dotnet meta docs metaobjects --out Docs # → Docs/api/csharp (AGENT-API.md +
72
72
  `AGENT-API.md` — the exact imports, signatures, and payload field shapes for the
73
73
  generated code. **Before calling any generated code, read `api/csharp/AGENT-API.md`.**
74
74
 
75
+ **The two `docs` positionals are NOT the same argument.** This one is the METADATA
76
+ directory. The Node `meta docs` positional — used by every stack, since `migrate`,
77
+ `verify --db` and the neutral model docs are Node-only — is the PROJECT ROOT that
78
+ CONTAINS the metadata. Run `meta docs` with no positional, from the project root:
79
+
80
+ ```bash
81
+ dotnet meta docs metaobjects --out Docs # C#: the METADATA dir
82
+ meta docs --out Docs # Node: run from the PROJECT ROOT (no positional)
83
+ ```
84
+
75
85
  ## Persistence + routes are the deployed artifact
76
86
 
77
87
  C# generates a *complete* server stack: the entity classes + `AppDbContext` ARE the
@@ -38,6 +38,16 @@ metaobjects docs ./metadata --out ./docs # → ./docs/api/python (AGENT-API.md
38
38
  `AGENT-API.md` — the exact imports, signatures, and payload field shapes for the
39
39
  generated code. **Before calling any generated code, read `api/python/AGENT-API.md`.**
40
40
 
41
+ **The two `docs` positionals are NOT the same argument.** This one is the METADATA
42
+ directory. The Node `meta docs` positional — used by every stack, since `migrate`,
43
+ `verify --db` and the neutral model docs are Node-only — is the PROJECT ROOT that
44
+ CONTAINS the metadata. Run `meta docs` with no positional, from the project root:
45
+
46
+ ```bash
47
+ metaobjects docs ./metadata --out ./docs # Python: the METADATA dir
48
+ meta docs --out ./docs # Node: run from the PROJECT ROOT (no positional)
49
+ ```
50
+
41
51
  ## Generators
42
52
 
43
53
  Wire generators by their stable name (`--generators <names>`), or run the default set.
@@ -67,6 +67,17 @@ block, and the inferred types.
67
67
  A second file, `.metaobjects/config.json`, holds static project state parseable by
68
68
  non-TS tooling; `meta init` scaffolds both plus the `metaobjects/` source dir.
69
69
 
70
+ `sources` in that file is where the metadata lives — `metaobjects/` is only its
71
+ DEFAULT value, so a project can point it anywhere. **Every entry is an OBJECT, never
72
+ a bare string**, and it names a DIRECTORY or a file:
73
+
74
+ ```jsonc
75
+ { "schema_version": 1, "sources": [{ "path": "model" }, { "path": "../shared/metadata" }] }
76
+ ```
77
+
78
+ Every command's directory argument (`meta docs <project-root>`, `--cwd`) is the
79
+ PROJECT ROOT that CONTAINS the metadata — never the metadata directory itself.
80
+
70
81
  ## The generators
71
82
 
72
83
  From `@metaobjectsdev/codegen-ts/generators` (server-side, framework-neutral):
@@ -122,10 +133,10 @@ cross-subtype 404. Conformance-gated by `fixtures/api-contract-conformance/tph`
122
133
  (HTTP wire shape) and `fixtures/persistence-conformance/tph-*` (single-table
123
134
  runtime semantics).
124
135
 
125
- ## Docs — `meta docs` (one door, two surfaces)
136
+ ## Docs — `meta docs` (one door, three surfaces)
126
137
 
127
138
  Documentation is NOT a `meta gen` generator. The single door is the `meta docs`
128
- command, which emits two cross-linked **surfaces** under one output dir (default
139
+ command, which emits three cross-linked **surfaces** under one output dir (default
129
140
  `./docs`):
130
141
 
131
142
  - **model surface** (`./docs/<Entity>.md`, `./docs/<Template>.md`) — the neutral
@@ -135,11 +146,19 @@ command, which emits two cross-linked **surfaces** under one output dir (default
135
146
  `./docs/api/AGENT-API.md`) — the SDK/API reference: the concrete imports,
136
147
  function signatures, payload field shapes, and runnable examples for *this*
137
148
  project's generated code.
149
+ - **requirements surface** (`./docs/requirements.md`, `./docs/requirements.toon`) —
150
+ the declared `requirement.*` ledger as documentation, with each entry headed by its
151
+ dotted path and its `title` where it has one, and each entity page naming the
152
+ requirements that claim it. Metadata-alone like the model surface, so it needs no
153
+ gen config. **On by default since 0.24.0** — a project declaring no `requirement.*`
154
+ nodes writes no requirements file and the run says nothing about the surface at all,
155
+ deliberately: reporting "0 requirement pages" would advertise a surface that never ran.
138
156
 
139
157
  ```bash
140
- npx meta docs # both surfaces → ./docs (model) + ./docs/api (api)
158
+ npx meta docs # all three → ./docs (model) + ./docs/api + ./docs/requirements.*
141
159
  npx meta docs --model # model surface only
142
160
  npx meta docs --api # api surface only
161
+ npx meta docs --requirements # requirement ledger only
143
162
  npx meta docs --out ./site-docs # write under a different root
144
163
  ```
145
164
 
@@ -18,13 +18,14 @@ DEFERRED, ON PURPOSE — capability requirements (`requirement.functional` /
18
18
  `requirement.architectural`). This skill deliberately says NOTHING about them, and that is a
19
19
  decision, not an omission.
20
20
 
21
- Why not now: (1) the grounding rule above requires every capability claim be verifiable
22
- against the CURRENT RELEASE, and `requirement.*` is not in one yet. (2) The signal->feature
23
- mapping is unvalidated against this skill's own kill criterion: the controlled evidence
24
- (0/24 model-only revivals) measures POST-adoption model-reading, and the feature's premise --
25
- that the disproof lives nowhere in the model -- cuts against pre-adoption detectability. If
26
- the reasoning was never written down, there is nothing for an evidence-cited assessment to
27
- cite. (3) Whether anyone fills the ledger in is itself untested; advertising it pre-adoption
21
+ Why not now: (1) SPENT -- this read "not in a release yet", and it is now. `requirement.*`
22
+ shipped in 0.22.0 and has evolved twice since (0.23.0 added `planned` / `@disposition` /
23
+ `@trackedBy`; 0.24.0 made the vocabulary prescriptive-only). Do not repeat this reason.
24
+ (2) The signal->feature mapping is unvalidated against this skill's own kill criterion: the
25
+ controlled evidence (0/24 model-only revivals) measures POST-adoption model-reading, and the
26
+ feature's premise -- that the disproof lives nowhere in the model -- cuts against pre-adoption
27
+ detectability. If the reasoning was never written down, there is nothing for an evidence-cited
28
+ assessment to cite. (3) Whether anyone fills the ledger in is itself untested; advertising it pre-adoption
28
29
  is the brochure failure this skill exists to avoid.
29
30
 
30
31
  TRIGGER to revisit — the release carrying `requirement.*` has shipped, AND either:
@@ -34,6 +35,23 @@ TRIGGER to revisit — the release carrying `requirement.*` has shipped, AND eit
34
35
  Arm B a team that adopted via this assessment hits a resurrection the ledger would have
35
36
  caught, or asks why the assessment never mentioned it.
36
37
 
38
+ ARM A HAS FIRED (dogfooded adopter estate, 2026-08-13). Three retired or reversed capability
39
+ decisions were each citable PRE-adoption at file:line -- a removal commit, two explicit
40
+ do-not-reintroduce comments on the very constant an agent would revive, an .env.example line
41
+ saying the knob does not exist. The ANTI-TRIGGER did NOT fire: every one traced to committed
42
+ prose. Bound it honestly -- n=1, and that estate is unusually disciplined about recording
43
+ reversals in co-located prose, which is precisely the manual work `requirement.*` systematizes,
44
+ so it is a confound rather than a clean sample. It also partly falsifies the feature's own
45
+ premise that the disproof lives nowhere in the model.
46
+
47
+ SO THE DEFERRAL NOW RESTS ON (3) ALONE, which makes it a judgement about VALUE rather than a
48
+ fact about the release. Unprompted uptake is still unmeasured, and a later cross-estate reading
49
+ sharpened the question: ledger value tracks whether the ledger is EXECUTABLE -- whether some
50
+ mechanism can falsify a claim -- not when it was written. The largest ledger measured carried
51
+ no harness and produced no defect found by any mechanism. Advertising that to a team that will
52
+ hand-maintain it is the brochure failure this skill exists to avoid. Revisiting means deciding
53
+ the SHAPE below is worth spending, not re-checking whether the capability exists.
54
+
37
55
  ANTI-TRIGGER (defer -> never): if dogfooded entries trace only to tribal knowledge with no
38
56
  repo artifact, this assessment structurally cannot speak to the feature as a finding.
39
57
 
@@ -46,8 +64,10 @@ R0 verdict line: no verdict may turn on it.
46
64
 
47
65
  # MetaObjects Fit & Migration Assessment
48
66
 
49
- _Assessment prompt v1 (post-Phase-0 refinement). Grounded against MetaObjects npm `0.17.x` /
50
- Maven `7.9.x` verify every capability claim against the current release before asserting it._
67
+ _Assessment prompt v1 (post-Phase-0 refinement). Body originally grounded against MetaObjects
68
+ npm `0.17.x` / Maven `7.9.x`; capability claims re-swept against npm `0.24.x` / Maven `7.24.x`
69
+ on 2026-08-25, but not every line has been re-derived since — verify every capability claim
70
+ against the current release before asserting it._
51
71
 
52
72
  You are an AI assistant running a **pre-adoption fit assessment** for MetaObjects
53
73
  (https://github.com/metaobjectsdev/metaobjects — the cross-language metadata standard:
@@ -265,11 +285,13 @@ signature class. Hunt all ten classes:
265
285
  **necessity test**: expressible when every output column is a passthrough
266
286
  (`origin.passthrough @from/@via`), a count/sum/avg/min/max (`origin.aggregate
267
287
  @agg/@of/@via`, row-scoped with `@filter`), a predicate quantifier (`origin.aggregate
268
- @agg: any|all`), an array rollup (`origin.aggregate @agg: collect`), a non-aggregate
269
- derived scalar (`origin.computed @expr`), an argmax-style "one related row's column"
270
- pick (`origin.first @via` covers the common `DISTINCT ON` / lateral-join shape),
271
- a soft-delete/status/type row-scope (an
272
- object-level `@filter` on `object.projection`), or `extends`-borrowed and joins
288
+ @agg: any|all`), an array rollup (`origin.aggregate @agg: collect` with `@of`
289
+ naming one column, or `@of` OMITTED since 0.24.1 to collect each related row as the
290
+ carrying field's declared value object, which is the `json_agg(row_to_json(...))` shape),
291
+ a non-aggregate derived scalar (`origin.computed @expr`), an argmax-style "one related
292
+ row's column" pick (`origin.first @via` covers the common `DISTINCT ON` / lateral-join
293
+ shape), a soft-delete/status/type row-scope (an object-level `@filter` on
294
+ `object.projection`), or `extends`-borrowed — and joins
273
295
  follow declared relationships/`identity.reference` FKs. Expressible → projection
274
296
  candidate (note: an unmodeled hand view is *unmanaged* — invisible to `verify --db`;
275
297
  modeling it is what makes it gateable). `DISTINCT ON` and lateral join are **not**
@@ -313,7 +335,7 @@ concrete — lead with it.
313
335
  | schema vs model (5 + the DDL copy in 6) | spine owns DDL via `meta migrate`; **`meta verify --db`** (Node `meta` only; PG/SQLite/D1) |
314
336
  | opaque JSON columns (6-implicit) | `object.value` + `field.object @storage: jsonb` — the shape gets ONE checkable declaration + generated codecs |
315
337
  | read-model SQL (8) | `object.projection` + `origin.*` generate the view DDL |
316
- | scattered prompts (10) | `template.prompt` + typed payload VO + external text; **`meta verify --templates`** / `Renderer.verify` fails when a `{{field}}` no longer matches the payload; `template.output` (FR-006) + the tolerant `extract` parser and output-format fragment (FR-010) generate both sides of the tag contract |
338
+ | scattered prompts (10) | `template.prompt` + typed payload VO + external text; **`meta verify --templates`** / `Renderer.verify` fails when a `{{field}}` no longer matches the payload; a **responding** `template.prompt` — one carrying `@responseRef` — generates parser-on-receipt (FR-006) plus the output-format fragment and tolerant `extract` (FR-010), i.e. both sides of the tag contract. A `template.output` is **outbound only** and emits no parser (ADR-0052, 0.24.0) |
317
339
  | the metadata itself | strict provenance (ADR-0023): unknown attrs fail load |
318
340
 
319
341
  State the honest limits in the same section: `verify` cannot catch semantic mismodeling
@@ -428,8 +450,11 @@ promising it or counting it in benefits:
428
450
  count them).
429
451
  - email/URL/IP regexes → `@stringFormat: email` / `field.uri` / `field.inet`.
430
452
  - opaque JSON columns → `object.value` + `field.object @storage: jsonb` (from P2-b).
431
- - inline prompts / ad-hoc payload dicts / regex output parsing → `template.prompt` /
432
- `template.output` / `template.toolcall`.
453
+ - inline prompts / ad-hoc payload dicts → `template.prompt` / `template.toolcall`; a
454
+ hand-rolled regex or JSON-scrape of an LLM **reply** → a *responding* `template.prompt`
455
+ (one carrying `@responseRef`), never `template.output` — the subtype's axis is DIRECTION
456
+ and an output is outbound only (ADR-0052). Rendered emails / documents / config exports
457
+ are what `template.output` is for.
433
458
  - doc comments hand-written in migrations (`COMMENT ON`) → the common `description` attr.
434
459
  - a recurring closed variant-set as N sibling modules → project-registered provider
435
460
  subtype — VOCAB CANDIDATE, advisory only, never load-bearing for the verdict.
@@ -572,9 +597,11 @@ Every prose prediction gets a claim. Ceiling statements (P5-b) MUST carry
572
597
  runtime; consumer hand-wires the FastAPI router + repository impl; `metaobjects`
573
598
  console script `gen`/`verify` (no migrate).
574
599
  - **Prompt pillar** (all five ports): render + payload-VO codegen + `verify` templates +
575
- `template.output` parser-on-receipt (FR-006) + output-format fragment & tolerant
576
- `extract` (FR-010). MCP exposure of declared prompts/tools: not shipped never
577
- promise it.
600
+ parser-on-receipt for a **responding `template.prompt`** (FR-006) + output-format fragment
601
+ & tolerant `extract` (FR-010). Since 0.24.0 that whole inbound tier keys off `@responseRef`
602
+ on a `template.prompt`; a `template.output` is outbound-only and emits none of it
603
+ (ADR-0052) — do not promise a parser for one. MCP exposure of declared
604
+ prompts/tools: not shipped — never promise it.
578
605
  - **Not shipped, never promise**: `api.*`/`operation.*`/`binding.*` declared-API surface;
579
606
  the cut `byte`/`short`/`class` field stubs (non-functional, removed from the registry);
580
607
  `index.fulltext`/`vector`/`spatial` (reserved, unregistered); native PG enums /
@@ -46,6 +46,28 @@ Two more are caught structurally rather than by a command: **generated-edited**
46
46
  **migration-vs-metadata** (migrations are emitted *from* metadata diffs, so they
47
47
  can't drift by construction).
48
48
 
49
+ ## Before any of this: the metadata has to LOAD — `meta upgrade` for retired vocabulary
50
+
51
+ `verify` cannot report drift in a model it could not read. The registry is sealed
52
+ (ADR-0023), so a retired name has **no deprecation shim** — it is a load error, and
53
+ the run stops before the first drift check. Retirements that bite an existing estate:
54
+ `@readOnly` → `@mutability` and `origin.collection` → `origin.aggregate @agg: collect`
55
+ (0.24.0); `@violation` → `@counterexample`, plus `@verifiedBy`, `@supersededBy` and
56
+ `@status: abandoned | superseded` on `requirement.*` (0.24.0, FR-038); and an index key
57
+ declaring **both** `@fields` and `@expr`, refused since 0.24.1.
58
+
59
+ ```
60
+ meta upgrade # previews every rewrite; writes nothing
61
+ meta upgrade --apply # makes them
62
+ ```
63
+
64
+ Node-only, because it edits the metadata documents every port shares — a Java, Python
65
+ or C# project runs `npx meta upgrade` against its own sources. Canonical JSON and YAML
66
+ alike. It rewrites from the same table the loader's errors are generated from, fixes
67
+ only what has one correct answer, and **refuses the rest with a non-zero exit** so a
68
+ pipeline cannot record a partial migration as done. See
69
+ `references/requirements.md` for what it refuses on a ledger and why.
70
+
49
71
  ## Run `meta verify` before you call a build done
50
72
 
51
73
  Make a bare `meta verify` the last step before you consider any MetaObjects work
@@ -13,6 +13,37 @@ subverb**: requirements are metadata, so they are checked on *every* `meta verif
13
13
  A typo'd `@status` fails the **load** ("failed to load metadata"), before verify runs. If you
14
14
  see that, no other diagnostic in the run is trustworthy — fix it first and re-run.
15
15
 
16
+ ## A load failure on RETIRED vocabulary — run `meta upgrade`, do not hand-sweep
17
+
18
+ `@status` is not the only way the load fails before any of this runs. **0.24.0** retired
19
+ `@violation` (→ `@counterexample`) and `@verifiedBy`; **0.24.1** made an index key `@fields`
20
+ **XOR** `@expr`, so a node declaring both is now `ERR_INVALID_INDEX`. **0.24.2 reversed half
21
+ of the 0.24.0 cut**: `abandoned` and `superseded` become **`@status: retired`**, and
22
+ `@supersededBy` is registered again — this time as a reference the loader resolves. There is no deprecation shim for any of them —
23
+ the registry is sealed (ADR-0023), so a legacy document does not load at all and every check on
24
+ this page is unreachable until it does.
25
+
26
+ ```
27
+ meta upgrade # previews every rewrite; writes nothing
28
+ meta upgrade --apply # makes them
29
+ ```
30
+
31
+ It rewrites from the same table the loader's error text is generated from, so the fix you are
32
+ told about and the edit the tool makes cannot drift apart. It fixes only what has one correct
33
+ answer and **refuses the rest, exiting non-zero** — a partial migration can never be recorded
34
+ as finished by CI. Two refusals are expected on a real ledger and both are yours to decide:
35
+
36
+ - **`@status: abandoned` / `superseded` are no longer refused** — they rewrite to `retired`,
37
+ and the same run drops the `@implementedBy` that a retired entry may not carry. This was the
38
+ canonical judgement case in 0.24.0; FR-039 made it determinate.
39
+ - **`origin.collection`.** Retired to `origin.aggregate @agg: collect`; the attribute sets
40
+ differ, so the tool will not guess.
41
+
42
+ `@fields` beside `@expr` **is** rewritten, and the survivor is not a coin toss: that pair loaded
43
+ before 0.24.1 with `@fields` silently discarded, so the index already in your database is the
44
+ expression one. `upgrade` drops `@fields` — which reproduces the object that exists and emits no
45
+ DDL change. Do not hand-pick the other survivor; that invents a new index and migrates live data.
46
+
16
47
  ## The status asymmetry — the one that surprises people
17
48
 
18
49
  The **same** unresolved `@implementedBy` reference is:
@@ -49,6 +80,8 @@ name the remedy; this table exists so you can act on one without re-deriving the
49
80
  | `ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS` | a `live`/`partial` `requirement.architectural` that nothing implements | a policy declared and applied to nothing. Claim the nodes it governs, or drop it to `planned` — which is exempt, because it is not applied yet by definition. |
50
81
  | `ERR_REQUIREMENT_LEVEL_NESTING` | a node's `@level` disagrees with the level of the parent it nests under | nesting IS the hierarchy. Move the node to the right parent rather than editing the level to match where it happens to sit. |
51
82
  | `ERR_REQUIREMENT_BAD_LEVEL` | `@level` is not an integer inside the allowed range | levels are L1–L5 and nothing else. |
83
+ | `ERR_REQUIREMENT_RETIRED_HAS_IMPLEMENTORS` | a `@status: retired` entry declares `@implementedBy` | a retired capability has no implementation by definition. Delete the attribute and put what used to implement it in `notes`; if the nodes are still there, the capability is `live` or `partial`, not retired. |
84
+ | `ERR_REQUIREMENT_SUPERSEDED_BY_NOT_RETIRED` | `@supersededBy` on a status other than `retired` | it names what REPLACED a withdrawn capability, so on a live one there is nothing for it to say. |
52
85
  | `ERR_MISSING_REQUIRED_ATTR` | a required attr is absent | `@statement`, `@status` and `@counterexample` are required on both subtypes; `@level` is required on `functional` and optional on `architectural`. |
53
86
  | `ERR_BAD_ATTR_VALUE` | a closed-enum attr has an unknown value | `@status` and `@disposition` are enforced by the LOADER, so a typo fails the load in every port rather than passing in some. |
54
87
 
@@ -56,6 +89,40 @@ name the remedy; this table exists so you can act on one without re-deriving the
56
89
  reasons, so reading only the code you hit can send you the wrong way. The question is not
57
90
  "is this ref valid?" — usually it is — but "does the LEVEL match the shape of the ref?"
58
91
 
92
+ ## The authoring lint — a second section, never an error
93
+
94
+ `verify` also prints an **authoring lint** under its own heading, after the gate's own
95
+ warnings:
96
+
97
+ ```
98
+ meta verify — requirements: 6 authoring warning(s) (advisory — does not fail the build):
99
+ ```
100
+
101
+ Read it as a different claim from everything above. The gate says the ledger **disagrees with
102
+ the model**; the lint says it agrees but **records less than its author thinks**. Every finding
103
+ is a warning and none can change the exit code, so a lint-only run is a passing run.
104
+
105
+ | code | what it means | fix |
106
+ |---|---|---|
107
+ | `WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE` | the `name` holds a character that breaks the dotted path or the generated stub filename | rename it. A `.` is the common one and the worst: `Orders.Recorded` and `Orders` containing `Recorded` produce the **same** path, so the address stops identifying one node. |
108
+ | `WARN_REQUIREMENT_NAME_READS_AS_PROSE` | the `name` is a sentence | the name is an address — make it an identifier and put the prose in `@statement`. |
109
+ | `WARN_REQUIREMENT_NAME_RESTATES_STATEMENT` | `name` and `@statement` say the same thing | the claim is written twice. Keep `@statement` (every surface reads it) and shorten the name. |
110
+ | `WARN_REQUIREMENT_PROSE_EMPTY` | `@statement` or `@counterexample` is present but blank | the loader requires the attr to EXIST, never to say anything. Write the sentence, or delete the entry. |
111
+ | `WARN_REQUIREMENT_PROSE_DUPLICATED` | `description` repeats `@statement` (whole, or as its opening sentence), or `@counterexample` does | `@statement` is already the description. `description` holds the SCOPE; drop it entirely if the scope is obvious. |
112
+ | `WARN_REQUIREMENT_INERT_DOC_SLOT` | `summary` is set on a requirement | `@statement` is already the required one-line sentence, so a summary can only repeat it, and nothing reads it. Delete it. (`title` is NOT flagged — it is chartered as the entry's label.) |
113
+ | `WARN_REQUIREMENT_TITLE_IS_AN_ID` | `title` holds a catalogue or ticket id | a title is a noun phrase and an id is not a name. **Split** it — the id to `@trackedBy`, the phrase stays the title. Do not move the whole string; that throws the label away. |
114
+
115
+ Findings from both sections are addressed by the requirement's **dotted path**, never its
116
+ bare name — two branches of a ledger may reuse a name, so a bare one can be ambiguous.
117
+
118
+ Mute the lint with `--no-requirement-lint` or `META_NO_REQUIREMENT_LINT=1`. That silences the
119
+ advisory half **only** — the gate above still runs and can still exit 1.
120
+
121
+ Two deliberate silences. The lint reports only **exact** repeats, never a paraphrase — a
122
+ similarity threshold on prose produces findings you can argue with. And it never judges
123
+ whether a statement is true or a counterexample sufficient; those are the judgements the
124
+ ledger exists to record.
125
+
59
126
  ## `verify` does not look at your tests
60
127
 
61
128
  It used to. `@verifiedBy` asked you to name a test, and `verify` checked that the **name**
@@ -1 +1 @@
1
- {"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"AAkBA,OAAO,EAKL,KAAK,cAAc,EAEpB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,UAAU;IACzB;2EACuE;IACvE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;4BAGwB;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,SAAS,cAAc,EAAE,CAAC;IAC5C;;;;;gFAK4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAC3C;;gFAE4E;IAC5E,QAAQ,CAAC,cAAc,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,SAAS,CAAC;IAChE;;;;2EAIuE;IACvE,QAAQ,CAAC,oBAAoB,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;CAC9D;AAaD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9B,OAAO,CAAC,UAAU,CAAC,CA0ErB"}
1
+ {"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"AAmBA,OAAO,EAKL,KAAK,cAAc,EAEpB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,UAAU;IACzB;2EACuE;IACvE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;4BAGwB;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,SAAS,cAAc,EAAE,CAAC;IAC5C;;;;;gFAK4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAC3C;;gFAE4E;IAC5E,QAAQ,CAAC,cAAc,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,SAAS,CAAC;IAChE;;;;2EAIuE;IACvE,QAAQ,CAAC,oBAAoB,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;CAC9D;AA8ED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9B,OAAO,CAAC,UAAU,CAAC,CA6FrB"}
@@ -10,13 +10,79 @@
10
10
  // today (`DEFAULT_SOURCES` in `sources.ts`); a project that declares
11
11
  // `sources` can point anywhere. No other call site may assume the directory
12
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";
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";
15
16
  import { CONFIG_FILE, loadConfig } from "./config.js";
16
17
  import { discoverCollectionRoot, exists, isDir } from "./discovery.js";
17
18
  import { compileScope, matchesScope } from "./scope.js";
18
- import { DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR } from "./metadata-files.js";
19
+ import { DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR, isMetadataFile } from "./metadata-files.js";
19
20
  import { DEFAULT_SOURCES, orderedPathSpecs, resolveSpecPath, resolveSources, } from "./sources.js";
21
+ /** The canonical-JSON document root key (`metadata.root`) and the sigil-free
22
+ * YAML root mapping key (`metadata:`, at column 0 — ADR-0006), built from the
23
+ * metamodel constants rather than spelled out. */
24
+ const JSON_ROOT_KEY = `${TYPE_METADATA}.${SUBTYPE_ROOT}`;
25
+ const YAML_ROOT_KEY = new RegExp(`^${TYPE_METADATA}\\s*:`, "m");
26
+ /** Bound on the sniff below: one hit is enough to diagnose, and this runs only
27
+ * on a path that has already failed. */
28
+ const MAX_SNIFFED_FILES = 20;
29
+ /**
30
+ * Cheap "is this file a metadata DOCUMENT?" sniff — for a diagnostic, never for
31
+ * loading. It decides whether to change an error MESSAGE; nothing downstream
32
+ * reads its answer, and a false negative costs only the generic message.
33
+ *
34
+ * A file-EXTENSION test cannot answer this: `package.json` and `tsconfig.json`
35
+ * carry a recognized metadata extension ({@link isMetadataFile}), so every JS
36
+ * project root that has no metadata yet would be misdiagnosed as a metadata
37
+ * directory — a confidently wrong hint, which is worse than the generic one it
38
+ * would replace. The document root is `metadata.root` in canonical JSON and the
39
+ * sigil-free `metadata:` key in YAML authoring (ADR-0006), so those are what
40
+ * this looks for.
41
+ */
42
+ async function sniffsAsMetadataDocument(file) {
43
+ const text = await readFile(file, "utf8").catch(() => undefined);
44
+ if (text === undefined)
45
+ return false;
46
+ if (extname(file).toLowerCase() !== ".json") {
47
+ // YAML: the root mapping key, at column 0. Not a parse — this package has
48
+ // no YAML parser, and acquiring one to improve an error message would be a
49
+ // dependency bought with nothing.
50
+ return YAML_ROOT_KEY.test(text);
51
+ }
52
+ try {
53
+ const doc = JSON.parse(text);
54
+ return typeof doc === "object" && doc !== null && JSON_ROOT_KEY in doc;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ /**
61
+ * Does `dir` hold metadata but carry no project marker — i.e. is it the
62
+ * METADATA directory, handed to a command whose directory argument is the
63
+ * PROJECT ROOT? (#344)
64
+ *
65
+ * That mistake is predictable rather than careless: the sibling `docs`
66
+ * positionals in the Python and C# ports ARE the metadata directory, and all
67
+ * three are spelled the same way. The generic diagnostic then advises declaring
68
+ * `sources`, which from inside the metadata directory is a dead end.
69
+ *
70
+ * Only the top level is scanned. A metadata directory has metadata files in it;
71
+ * recursing would walk `node_modules/` on the very input this must NOT
72
+ * misdiagnose.
73
+ */
74
+ async function holdsMetadataButIsNoRoot(dir, hasConfig) {
75
+ // A directory carrying the marker IS a project root, by the only definition
76
+ // of one there is (`discovery.ts`). Nothing to diagnose.
77
+ if (hasConfig)
78
+ return false;
79
+ const entries = await readdir(dir).catch(() => []);
80
+ for (const entry of entries.filter(isMetadataFile).slice(0, MAX_SNIFFED_FILES)) {
81
+ if (await sniffsAsMetadataDocument(join(dir, entry)))
82
+ return true;
83
+ }
84
+ return false;
85
+ }
20
86
  /** Narrow the zod-inferred `Config["scope"]` (whose `.optional()` fields are
21
87
  * typed `T | undefined` even when present) down to `Scope`'s
22
88
  * exactOptionalPropertyTypes-safe shape — a key is omitted entirely rather
@@ -98,8 +164,24 @@ export async function resolveCollection(startDir, opts) {
98
164
  // whenever a discovered config declares no `sources`, where nothing has
99
165
  // probed it at all.
100
166
  if (specs === DEFAULT_SOURCES && !(await isDir(join(configDir, DEFAULT_METADATA_DIR)))) {
167
+ // #344 — name the one wrong answer worth naming before falling back to the
168
+ // generic advice. Told "declare sources" while standing in the metadata
169
+ // directory, an author declares the metadata FILES, and the config schema
170
+ // then rejects a bare string (a `sources` entry is `{ "path": … }`) — two
171
+ // dead ends in a row for a caller whose only mistake was passing the
172
+ // directory the sibling ports' `docs` positional wants.
173
+ if (await holdsMetadataButIsNoRoot(configDir, hasConfig)) {
174
+ const parent = dirname(configDir);
175
+ const near = await discoverCollectionRoot(parent);
176
+ const suggested = near.hasConfig ? near.dir : parent;
177
+ throw new ParseError(`${configDir} looks like a metadata directory, not a project root — it holds metadata ` +
178
+ `files but carries no ${DEFAULT_METAOBJECTS_DIR}/${CONFIG_FILE}. A directory argument here is ` +
179
+ `the PROJECT ROOT that CONTAINS your metadata; where the metadata lives is then the root's ` +
180
+ `"sources" (default: the "${DEFAULT_METADATA_DIR}" directory beneath it). Try ${suggested} instead.`, { code: "ERR_COLLECTION_NOT_FOUND", source: codeSource("resolveCollection") });
181
+ }
101
182
  throw new ParseError(`no metadata sources declared in ${configDir} and no default "${DEFAULT_METADATA_DIR}" directory found. ` +
102
- `Declare "sources" in ${DEFAULT_METAOBJECTS_DIR}/config.json, or run 'meta init' to scaffold.`, { code: "ERR_COLLECTION_NOT_FOUND", source: codeSource("resolveCollection") });
183
+ `Declare "sources" in ${DEFAULT_METAOBJECTS_DIR}/${CONFIG_FILE} each entry is an OBJECT, ` +
184
+ `e.g. "sources": [{ "path": "model" }] — or run 'meta init' to scaffold.`, { code: "ERR_COLLECTION_NOT_FOUND", source: codeSource("resolveCollection") });
103
185
  }
104
186
  const sources = await resolveSources(configDir, specs);
105
187
  const scope = compileScope(toScope(scopeSpec));
@@ -1 +1 @@
1
- {"version":3,"file":"collection.js","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"collection.js","sourceRoot":"","sources":["../src/collection.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.24.0",
3
+ "version": "0.24.2",
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.24.0",
59
+ "@metaobjectsdev/metadata": "0.24.2",
60
60
  "zod": "^3.23.0"
61
61
  },
62
62
  "devDependencies": {
package/src/collection.ts CHANGED
@@ -10,12 +10,13 @@
10
10
  // today (`DEFAULT_SOURCES` in `sources.ts`); a project that declares
11
11
  // `sources` can point anywhere. No other call site may assume the directory
12
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";
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";
15
16
  import { CONFIG_FILE, loadConfig, type Config } from "./config.js";
16
17
  import { discoverCollectionRoot, exists, isDir } from "./discovery.js";
17
18
  import { compileScope, matchesScope, type Scope } from "./scope.js";
18
- import { DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR } from "./metadata-files.js";
19
+ import { DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR, isMetadataFile } from "./metadata-files.js";
19
20
  import {
20
21
  DEFAULT_SOURCES,
21
22
  orderedPathSpecs,
@@ -68,6 +69,71 @@ export interface Collection {
68
69
  readonly migrateScopePatterns: readonly string[] | undefined;
69
70
  }
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
+
71
137
  /** Narrow the zod-inferred `Config["scope"]` (whose `.optional()` fields are
72
138
  * typed `T | undefined` even when present) down to `Scope`'s
73
139
  * exactOptionalPropertyTypes-safe shape — a key is omitted entirely rather
@@ -155,9 +221,28 @@ export async function resolveCollection(
155
221
  // whenever a discovered config declares no `sources`, where nothing has
156
222
  // probed it at all.
157
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
+ }
158
242
  throw new ParseError(
159
243
  `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.`,
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.`,
161
246
  { code: "ERR_COLLECTION_NOT_FOUND", source: codeSource("resolveCollection") },
162
247
  );
163
248
  }