Skip to content

Commit cd81349

Browse files
dmealingclaude
andcommitted
fix(cli): meta types under-reported the registry it is the search for (#357)
`meta types` is the vocabulary search the generated AGENTS.md and the metaobjects-authoring skill both make step 1 of the authoring procedure, on the reasoning that you must search the vocabulary before concluding something cannot be expressed. It gave confidently wrong answers, for two independent reasons. It read the cross-port manifest. buildRegistryManifest answers "what must all five ports byte-match?" and deliberately carves out the 13 TS-web-presentation view.* controls (B-2), which stay REGISTERED in TypeScript. Asked "what can I author here?", it reported 2 of the 15 registered view.* subtypes, and the other 13 came back exactly as a genuine typo does. And it composed a partial registry -- registerCoreTypes alone, which core-types.ts itself documents as a legacy wrapper to be preferred against. Every attr the db, ui-web and documentation providers register was invisible: field.string reported 6 attrs instead of 16 (no @column, @filterable, @Sortable, @dbColumnType), view.textarea none at all (no @rows), and the eight documentation common attrs were absent entirely -- so `meta types title` found nothing, for the very attr an author is meant to find INSTEAD of asking for a new one. That omission is how #353 became a request to register @Label when @title already existed: the tool that would have prevented it under-reported the attribute involved. The command now composes the same provider set the loader does, and reads a new buildVocabularyCatalog -- the authoring-facing twin of the manifest, built from the same row rules -- which enumerates every registered (type, subType) and MARKS the cross-port carve-outs [ts-only] rather than dropping them. That is the honest way to surface what the carve-out means: not "missing", but "TypeScript-only". A type's shared root is marked [base] so a listing cannot be mistaken for the concrete vocabulary; it is deliberately not called "abstract", since nothing in the registry records abstractness and some base rows (attr.base) are the authorable polymorphic form. Common attrs are searchable and no --type scope hides them, since they are accepted on every node. Reserved structural keys (isArray, extends) stay unlisted: @-prefixing one is ERR_RESERVED_ATTR, so offering them would teach metadata the loader rejects. An unregistered subtype still reports as missing -- the fix must not make the tool answer "yes" to everything. TypeScript-only, and deliberately so. The B-2 exclusion is correct and documented; adding those rows to expected-registry.json would break the C#/Python deregistration and force a cross-port change for a bug whose cause was a CLI command asking the wrong source. expected-registry.json and metamodelVersion are untouched, and registry-conformance is unchanged. Both halves of the fix were proven by reverting each in turn: the carve-out reinstated fails 6 of the 10 new tests, the partial composition fails 4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HSCgs7z19w8aGXGiceCohC
1 parent 16e9124 commit cd81349

6 files changed

Lines changed: 349 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,45 @@ same `[0]` and, though source-only (ADR-0048), builds in-repo and would have dri
4848
grid tier it is modelled on. No generated output in this repo changes: not one model here
4949
declares two views on a field, which is also why nothing caught this.
5050

51+
### Fixed — `meta types` under-reported the registry it is the search for (#357)
52+
53+
`meta types` is the vocabulary search the generated `.metaobjects/AGENTS.md` and the
54+
`metaobjects-authoring` skill both make **step 1** of the authoring procedure, on the reasoning
55+
that you must search the vocabulary before concluding something cannot be expressed. It gave
56+
confidently wrong answers, for two independent reasons:
57+
58+
1. **It read the cross-port manifest.** `buildRegistryManifest` answers *"what must all five
59+
ports byte-match?"* and deliberately carves out the 13 TS-web-presentation `view.*` controls
60+
(B-2), which stay REGISTERED in TypeScript. Asked *"what can I author here?"*, it therefore
61+
reported **2 of the 15** registered `view.*` subtypes, and the other 13 came back exactly as
62+
a genuine typo does: `meta types view.text` → *"No vocabulary matches"*.
63+
2. **It composed a partial registry** — `registerCoreTypes` alone, which `core-types.ts` itself
64+
documents as a legacy wrapper to be preferred against. Every attr the db, ui-web and
65+
documentation providers register was invisible: `field.string` reported **6** attrs instead
66+
of 16 (no `@column`, `@filterable`, `@sortable`, `@dbColumnType`), `view.textarea` none at
67+
all (no `@rows`), and the eight documentation common attrs were absent entirely — so
68+
**`meta types title` found nothing**, for the very attr an author is meant to find instead
69+
of asking for a new one. That omission is exactly how #353 became a request to register
70+
`@label` when `@title` already existed: the tool that would have prevented it under-reported
71+
the attribute involved.
72+
73+
The command now composes the same provider set the loader does, and reads a new
74+
`buildVocabularyCatalog` — the authoring-facing twin of the manifest, built from the same row
75+
rules — which enumerates every registered `(type, subType)` and **marks** the cross-port
76+
carve-outs `[ts-only]` rather than dropping them, which is the honest way to surface what the
77+
carve-out means: not "missing", but "TypeScript-only". A type's shared root (`<type>.base`) is
78+
marked `[base]`, so a listing cannot be mistaken for the concrete vocabulary. Common attrs are
79+
searchable and no `--type` scope hides them, since they are accepted on every node.
80+
81+
Reserved structural keys (`isArray`, `extends`) stay unlisted: `@`-prefixing one is
82+
`ERR_RESERVED_ATTR`, so offering them would teach metadata the loader rejects. An unregistered
83+
subtype still reports as missing — the fix must not make the tool answer "yes" to everything.
84+
85+
**TypeScript-only, and deliberately so.** The B-2 exclusion is correct and documented; adding
86+
those rows to `expected-registry.json` would break the C#/Python deregistration and force a
87+
cross-port change for a bug whose cause was a CLI command asking the wrong source.
88+
`expected-registry.json` and `metamodelVersion` are untouched.
89+
5190
### Fixed — `verify --codegen` convicted the output `gen` had just written (C#, Python)
5291

5392
The declarative Mustache template-spec was wired into `gen` and nowhere else, so the drift

server/typescript/packages/cli/src/commands/types.ts

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,8 @@
22
// without loading it all into context. apropos + `kubectl explain` over the live
33
// registry, tuned for an agent's token budget: terse names-first default, opt-in
44
// description search, drill-in `--detail`, machine-readable `--json`.
5-
import {
6-
TypeRegistry,
7-
registerCoreTypes,
8-
buildRegistryManifest,
9-
} from "@metaobjectsdev/metadata";
10-
import { registerForgeTypes } from "@metaobjectsdev/sdk";
5+
import { composeRegistry, coreProviders, buildVocabularyCatalog } from "@metaobjectsdev/metadata";
6+
import { forgeTypesProvider } from "@metaobjectsdev/sdk";
117
import { log } from "../lib/log.js";
128

139
interface TypesFlags {
@@ -78,7 +74,24 @@ interface Entry {
7874
description: string;
7975
whenToUse: string | undefined;
8076
attrNames: string[]; // attr-name hints (for subtype terse lines)
81-
raw: unknown; // the manifest node, for --json
77+
/** Accepted on EVERY node, so no `--type` scope excludes it (#357). */
78+
common: boolean;
79+
/** Registered here but carved out of the cross-port manifest — TS-only vocabulary. */
80+
tsOnly: boolean;
81+
/** A type's shared root (`<type>.base`): its attrs apply to every subtype. */
82+
sharedRoot: boolean;
83+
raw: unknown; // the catalog node, for --json
84+
}
85+
86+
/** Compact markers appended to a terse line, and the legend that explains them. */
87+
const MARK_TS_ONLY = "[ts-only]";
88+
const MARK_BASE = "[base]";
89+
const LEGEND = ` ${MARK_BASE} the type's shared root — its @attrs apply to every subtype of that type\n` +
90+
` ${MARK_TS_ONLY} registered in TypeScript only; the cross-port metamodel contract does not carry it`;
91+
92+
function marks(e: Entry): string {
93+
const m = [e.sharedRoot ? MARK_BASE : "", e.tsOnly ? MARK_TS_ONLY : ""].filter(Boolean);
94+
return m.length > 0 ? ` ${m.join(" ")}` : "";
8295
}
8396

8497
export async function typesCommand(args: string[]): Promise<number> {
@@ -94,35 +107,55 @@ export async function typesCommand(args: string[]): Promise<number> {
94107
return 0;
95108
}
96109

97-
const registry = new TypeRegistry();
98-
registerCoreTypes(registry);
99-
registerForgeTypes(registry);
100-
const manifest = buildRegistryManifest(registry);
101-
102-
// Flatten the manifest into searchable entries.
110+
// #357 — COMPOSE the registry, never `registerCoreTypes` alone. The db, ui-web and
111+
// documentation providers register attrs onto types the core provider declares, so a
112+
// partially-composed registry reports a type that exists with most of its attributes
113+
// missing: `field.string` came back with 6 attrs instead of 16 (no @column, @filterable,
114+
// @sortable, @dbColumnType), `view.textarea` with none (no @rows), and the eight
115+
// documentation commonAttrs — @title among them — were absent entirely. This is the same
116+
// provider set the loader composes, so what this prints is what the loader accepts.
117+
const registry = composeRegistry([...coreProviders, forgeTypesProvider], { validate: true });
118+
const catalog = buildVocabularyCatalog(registry);
119+
120+
// Flatten the catalog into searchable entries.
103121
const entries: Entry[] = [];
104-
for (const mt of manifest.types) {
122+
for (const mt of catalog.types) {
105123
const tsName = `${mt.type}.${mt.subType}`;
106124
entries.push({
107125
kind: "subtype", name: tsName, owner: tsName, type: mt.type,
108126
description: mt.description, whenToUse: mt.whenToUse,
109-
attrNames: mt.attrs.map((a) => `@${a.name}`), raw: mt,
127+
attrNames: mt.attrs.map((a) => `@${a.name}`),
128+
common: false, tsOnly: !mt.crossPort, sharedRoot: mt.sharedRoot, raw: mt,
110129
});
111130
for (const at of mt.attrs) {
112131
entries.push({
113132
kind: "attr", name: `${tsName} @${at.name}`, owner: tsName, type: mt.type,
114-
description: at.description, whenToUse: at.whenToUse, attrNames: [], raw: at,
133+
description: at.description, whenToUse: at.whenToUse, attrNames: [],
134+
common: false, tsOnly: !mt.crossPort, sharedRoot: false, raw: at,
115135
});
116136
}
117137
}
138+
// #357 — the attrs every node accepts. These were absent from the search entirely, so
139+
// `meta types title` reported nothing for `@title` — the registered attr an author is
140+
// supposed to find INSTEAD of asking for a new one (that omission is exactly how #353
141+
// became a request to register `@label`). They belong to no single type, so no `--type`
142+
// scope excludes them.
143+
for (const at of catalog.commonAttrs) {
144+
entries.push({
145+
kind: "attr", name: `@${at.name}`, owner: "(any node)", type: "",
146+
description: at.description, whenToUse: at.whenToUse, attrNames: [],
147+
common: true, tsOnly: false, sharedRoot: false, raw: at,
148+
});
149+
}
118150

119151
const q = (f.query ?? "").toLowerCase();
120152
const wantKind = (e: Entry) => f.kind.size === 0
121153
? true
122154
: (f.kind.has("attr") && e.kind === "attr") ||
123155
((f.kind.has("subtype") || f.kind.has("type")) && e.kind === "subtype");
124156
const matches = entries.filter((e) => {
125-
if (f.type && e.type !== f.type) return false;
157+
// A common attr is accepted on every type, so `--type field` must not hide it.
158+
if (f.type && e.type !== f.type && !e.common) return false;
126159
if (!wantKind(e)) return false;
127160
if (!q) return true;
128161
if (e.name.toLowerCase().includes(q)) return true;
@@ -149,9 +182,12 @@ export async function typesCommand(args: string[]): Promise<number> {
149182

150183
if (f.detail) {
151184
for (const e of shown) {
152-
const head = e.kind === "subtype" ? e.name : e.name;
153-
log.info(`\n${head} (${e.kind})`);
185+
log.info(`\n${e.name} (${e.kind})`);
154186
if (e.description) log.info(` ${e.description}`);
187+
if (e.sharedRoot)
188+
log.info(` shared root: @attrs registered here apply to every ${e.type}.* subtype.`);
189+
if (e.tsOnly)
190+
log.info(" TypeScript-only: registered here, but not part of the cross-port metamodel contract.");
155191
if (e.whenToUse) log.info(` → reach for it when: ${e.whenToUse}`);
156192
if (e.kind === "subtype") {
157193
const mt = e.raw as { attrs: { name: string; valueType: string | null; required: boolean; description: string }[] };
@@ -167,12 +203,13 @@ export async function typesCommand(args: string[]): Promise<number> {
167203
for (const e of shown) {
168204
if (e.kind === "subtype") {
169205
const hint = e.attrNames.length ? ` (${e.attrNames.slice(0, 6).join(", ")}${e.attrNames.length > 6 ? ", …" : ""})` : "";
170-
log.info(`${e.name.padEnd(28)} ${oneLine(e.description)}${hint}`);
206+
log.info(`${e.name.padEnd(28)} ${oneLine(e.description)}${hint}${marks(e)}`);
171207
} else {
172-
log.info(`${e.name.padEnd(28)} ${oneLine(e.description)}`);
208+
log.info(`${e.name.padEnd(28)} ${oneLine(e.description)}${marks(e)}`);
173209
}
174210
}
175211
}
212+
if (!f.noHeaders && !f.detail && shown.some((e) => marks(e) !== "")) log.info(`\n${LEGEND}`);
176213
if (!f.noHeaders && shown.length < total)
177214
log.info(`\n${shown.length} of ${total} shown — narrow with QUERY/--type/--kind or raise --limit.`);
178215
else if (!f.noHeaders)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// #357 — `meta types` is the vocabulary search the generated `.metaobjects/AGENTS.md` and
2+
// the `metaobjects-authoring` skill both make STEP 1 of the authoring procedure, on the
3+
// reasoning that you must search the vocabulary before concluding something cannot be
4+
// expressed. It under-reported the registry two ways at once, so following the documented
5+
// procedure produced a confidently wrong answer:
6+
//
7+
// 1. It read `buildRegistryManifest`, whose job is the byte-gated FIVE-PORT contract.
8+
// That deliberately carves out the 13 TS-web-presentation `view.*` controls (B-2),
9+
// which stay REGISTERED in TypeScript. So 9 of 10 authorable view subtypes reported
10+
// exactly as a genuine typo does: `meta types view.text` → "No vocabulary matches".
11+
// 2. It composed the registry with `registerCoreTypes` alone rather than
12+
// `composeRegistry(coreProviders)`, so every attr the db / ui-web / documentation
13+
// providers register was invisible — `field.string` with 6 attrs instead of 16, and
14+
// NO commonAttrs at all, which is why `meta types title` found nothing for the very
15+
// attr #353 should have been answered with.
16+
//
17+
// These assert the tool's ANSWER, not its internals: each is a question an author asks.
18+
19+
import { describe, test, expect, spyOn } from "bun:test";
20+
import { typesCommand } from "../src/commands/types.js";
21+
22+
/** Run `meta types ...` and return everything it printed to stdout. */
23+
async function run(args: string[]): Promise<string> {
24+
const lines: string[] = [];
25+
const spy = spyOn(console, "log").mockImplementation((...a: unknown[]) => {
26+
lines.push(a.map(String).join(" "));
27+
});
28+
try {
29+
expect(await typesCommand(args)).toBe(0);
30+
} finally {
31+
spy.mockRestore();
32+
}
33+
return lines.join("\n");
34+
}
35+
36+
describe("#357 every registered view subtype is findable", () => {
37+
test("a named lookup of a TS-only control resolves", async () => {
38+
// The issue's headline repro: this printed "No vocabulary matches \"view.text\"".
39+
const out = await run(["view.text"]);
40+
expect(out).toContain("view.text");
41+
expect(out).not.toContain("No vocabulary matches");
42+
});
43+
44+
test("listing the type reports all 15 registered subtypes, not 2", async () => {
45+
const out = await run(["--type", "view", "--kind", "subtype", "--limit", "0"]);
46+
for (const sub of [
47+
"base", "text", "textarea", "date", "month", "hotlink", "dropdown", "radio",
48+
"checkbox", "number", "password", "hidden", "web", "currency", "image",
49+
]) {
50+
expect(out).toContain(`view.${sub}`);
51+
}
52+
expect(out).toContain("15 matches.");
53+
});
54+
55+
test("a control the loader rejects is still reported missing", async () => {
56+
// The fix must not make the tool answer "yes" to everything: an unregistered
57+
// subtype has to stay indistinguishable from a typo, because it IS one.
58+
expect(await run(["view.bogusxyz"])).toContain("No vocabulary matches");
59+
});
60+
61+
test("TS-only vocabulary is marked rather than hidden", async () => {
62+
const out = await run(["--type", "view", "--kind", "subtype", "--limit", "0"]);
63+
// `view.currency` is in the cross-port contract; `view.text` is not.
64+
expect(out).toMatch(/view\.text .*\[ts-only\]/);
65+
expect(out).not.toMatch(/view\.currency .*\[ts-only\]/);
66+
expect(out).toContain("cross-port metamodel contract does not carry it");
67+
});
68+
69+
test("a type's shared root is marked, so the concrete list is not mistaken for it", async () => {
70+
expect(await run(["--type", "view", "--kind", "subtype", "--limit", "0"]))
71+
.toMatch(/view\.base .*\[base\]/);
72+
});
73+
});
74+
75+
describe("#357 the registry is COMPOSED, so attrs are not missing", () => {
76+
test("a db-provider attr on a core type is findable", async () => {
77+
// @filterable/@column are registered by the db provider onto field.*; with only
78+
// registerCoreTypes they did not exist as far as this command was concerned.
79+
const out = await run(["field.string", "--limit", "0"]);
80+
expect(out).toContain("@filterable");
81+
expect(out).toContain("@column");
82+
expect(out).toContain("@dbColumnType");
83+
});
84+
85+
test("a ui-web-provider attr is findable", async () => {
86+
expect(await run(["view.textarea", "--limit", "0"])).toContain("@rows");
87+
});
88+
89+
test("@title — the attr #353 should have found — is findable by name", async () => {
90+
const out = await run(["title"]);
91+
expect(out).toContain("@title");
92+
expect(out).not.toContain("No vocabulary matches");
93+
});
94+
95+
test("a common attr is not hidden by a --type scope", async () => {
96+
// @title is accepted on every node, so scoping to one type must not exclude it.
97+
expect(await run(["--type", "view", "title"])).toContain("@title");
98+
});
99+
100+
test("reserved structural keys are NOT offered as attrs", async () => {
101+
// `isArray`/`extends` are bare structural keys; `@`-prefixing one is ERR_RESERVED_ATTR,
102+
// so listing them would teach metadata the loader rejects.
103+
const out = await run(["--kind", "attr", "--limit", "0", "isArray"]);
104+
expect(out).toContain("No vocabulary matches");
105+
});
106+
});

server/typescript/packages/metadata/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ export { validateConstraints } from "./constraint-validate.js";
178178

179179
// Registry conformance manifest (SP-G) — the canonical logical-vocabulary serializer.
180180
export { buildRegistryManifest, emitRegistryManifest, classifyPerTypeAttr, METAMODEL_VERSION } from "./registry-manifest.js";
181+
// #357 — the AUTHORING-facing twin of buildRegistryManifest: every registered
182+
// (type, subType) this port accepts, with the cross-port carve-outs MARKED rather
183+
// than dropped. `meta types` reads this; the five-port byte gate reads the manifest.
184+
export { buildVocabularyCatalog } from "./vocabulary-catalog.js";
185+
export type { VocabularyCatalog, VocabularyType } from "./vocabulary-catalog.js";
181186
export type { AttrClassification } from "./registry-manifest.js";
182187
export { ExclusionReason } from "./registry-manifest-exclusions.js";
183188

server/typescript/packages/metadata/src/registry-manifest.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
} from "./registry-manifest-exclusions.js";
3838

3939
/** One attribute in the manifest — the logical, cross-port-identical facet. */
40-
interface ManifestAttr {
40+
export interface ManifestAttr {
4141
name: string;
4242
/** The attr's SCALAR value-type subtype, or null for a polymorphic/untyped attr (e.g. @default). */
4343
valueType: string | null;
@@ -63,7 +63,7 @@ interface ManifestAttr {
6363
}
6464

6565
/** One structural child rule of a type (FR-033 constraint graph). */
66-
interface ManifestChild {
66+
export interface ManifestChild {
6767
/** The admitted child `type` (`"*"` = any). */
6868
childType: string;
6969
/** The admitted child subType — a single subtype, `"*"` (any), or a list of admitted subtypes. */
@@ -79,7 +79,7 @@ interface ManifestChild {
7979
}
8080

8181
/** One registered (type, subType) in the manifest, with its docs + attrs + constraint graph. */
82-
interface ManifestType {
82+
export interface ManifestType {
8383
type: string;
8484
subType: string;
8585
/** FR-033 — human/AI-facing description of the type/subType (required, non-empty). */
@@ -124,7 +124,7 @@ interface RegistryManifest {
124124
}
125125

126126
/** ASCII-string compare so the sort is locale-independent and byte-stable across ports. */
127-
function compareStrings(a: string, b: string): number {
127+
export function compareStrings(a: string, b: string): number {
128128
return a < b ? -1 : a > b ? 1 : 0;
129129
}
130130

@@ -195,7 +195,7 @@ function toManifestChild(rule: ChildRule): ManifestChild {
195195
}
196196

197197
/** Sort the constraint graph by (childType, childSubTypeKey, childName) — ASCII. */
198-
function sortedChildren(rules: readonly ChildRule[]): ManifestChild[] {
198+
export function sortedChildren(rules: readonly ChildRule[]): ManifestChild[] {
199199
return rules
200200
.map(toManifestChild)
201201
.sort(
@@ -207,7 +207,7 @@ function sortedChildren(rules: readonly ChildRule[]): ManifestChild[] {
207207
}
208208

209209
/** Sort attrs by name (ascending, ASCII). */
210-
function sortedAttrs(attrs: readonly AttrSchema[]): ManifestAttr[] {
210+
export function sortedAttrs(attrs: readonly AttrSchema[]): ManifestAttr[] {
211211
return attrs
212212
.map(toManifestAttr)
213213
.sort((a, b) => compareStrings(a.name, b.name));
@@ -260,7 +260,8 @@ function sortedPerTypeAttrs(
260260
* judge a carve-out "dead" — that is a cross-port property, asserted by the
261261
* shared byte-canonical, not here.
262262
*/
263-
const INCLUDED = "included" as const;
263+
/** The classifier's "this is logical cross-port vocabulary" verdict. */
264+
export const INCLUDED = "included" as const;
264265
export type AttrClassification = ExclusionReason | typeof INCLUDED;
265266

266267
export function classifyPerTypeAttr(name: string): AttrClassification {

0 commit comments

Comments
 (0)