@metaobjectsdev/cli 0.24.4 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +2 -1
  2. package/dist/src/commands/docs.d.ts +14 -1
  3. package/dist/src/commands/docs.d.ts.map +1 -1
  4. package/dist/src/commands/docs.js +153 -8
  5. package/dist/src/commands/docs.js.map +1 -1
  6. package/dist/src/commands/eject.d.ts +38 -0
  7. package/dist/src/commands/eject.d.ts.map +1 -0
  8. package/dist/src/commands/eject.js +233 -0
  9. package/dist/src/commands/eject.js.map +1 -0
  10. package/dist/src/commands/gen.d.ts.map +1 -1
  11. package/dist/src/commands/gen.js +43 -20
  12. package/dist/src/commands/gen.js.map +1 -1
  13. package/dist/src/commands/init.d.ts +15 -1
  14. package/dist/src/commands/init.d.ts.map +1 -1
  15. package/dist/src/commands/init.js +208 -20
  16. package/dist/src/commands/init.js.map +1 -1
  17. package/dist/src/commands/types.d.ts +2 -1
  18. package/dist/src/commands/types.d.ts.map +1 -1
  19. package/dist/src/commands/types.js +165 -28
  20. package/dist/src/commands/types.js.map +1 -1
  21. package/dist/src/commands/verify.d.ts +9 -1
  22. package/dist/src/commands/verify.d.ts.map +1 -1
  23. package/dist/src/commands/verify.js +299 -51
  24. package/dist/src/commands/verify.js.map +1 -1
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +105 -4
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/lib/advisory.d.ts +77 -0
  29. package/dist/src/lib/advisory.d.ts.map +1 -0
  30. package/dist/src/lib/advisory.js +97 -0
  31. package/dist/src/lib/advisory.js.map +1 -0
  32. package/dist/src/lib/anti-patterns.d.ts +27 -3
  33. package/dist/src/lib/anti-patterns.d.ts.map +1 -1
  34. package/dist/src/lib/anti-patterns.js +145 -8
  35. package/dist/src/lib/anti-patterns.js.map +1 -1
  36. package/dist/src/lib/args.d.ts +35 -1
  37. package/dist/src/lib/args.d.ts.map +1 -1
  38. package/dist/src/lib/args.js +32 -1
  39. package/dist/src/lib/args.js.map +1 -1
  40. package/dist/src/lib/detect-stack.d.ts.map +1 -1
  41. package/dist/src/lib/detect-stack.js +4 -13
  42. package/dist/src/lib/detect-stack.js.map +1 -1
  43. package/dist/src/lib/docs-drift.d.ts +31 -0
  44. package/dist/src/lib/docs-drift.d.ts.map +1 -0
  45. package/dist/src/lib/docs-drift.js +195 -0
  46. package/dist/src/lib/docs-drift.js.map +1 -0
  47. package/dist/src/lib/format.d.ts +10 -0
  48. package/dist/src/lib/format.d.ts.map +1 -1
  49. package/dist/src/lib/format.js +15 -0
  50. package/dist/src/lib/format.js.map +1 -1
  51. package/dist/src/lib/output.d.ts +13 -0
  52. package/dist/src/lib/output.d.ts.map +1 -1
  53. package/dist/src/lib/output.js +16 -1
  54. package/dist/src/lib/output.js.map +1 -1
  55. package/dist/src/lib/package-manifest.d.ts +27 -0
  56. package/dist/src/lib/package-manifest.d.ts.map +1 -0
  57. package/dist/src/lib/package-manifest.js +52 -0
  58. package/dist/src/lib/package-manifest.js.map +1 -0
  59. package/package.json +11 -11
  60. package/src/commands/docs.ts +194 -8
  61. package/src/commands/eject.ts +282 -0
  62. package/src/commands/gen.ts +54 -19
  63. package/src/commands/init.ts +217 -19
  64. package/src/commands/types.ts +185 -34
  65. package/src/commands/verify.ts +358 -47
  66. package/src/index.ts +114 -5
  67. package/src/lib/advisory.ts +150 -0
  68. package/src/lib/anti-patterns.ts +163 -8
  69. package/src/lib/args.ts +74 -2
  70. package/src/lib/detect-stack.ts +4 -11
  71. package/src/lib/docs-drift.ts +222 -0
  72. package/src/lib/format.ts +14 -0
  73. package/src/lib/output.ts +33 -2
  74. package/src/lib/package-manifest.ts +58 -0
@@ -1,14 +1,29 @@
1
1
  // `meta types [QUERY]` — search the metadata vocabulary (types, subtypes, @attrs)
2
2
  // without loading it all into context. apropos + `kubectl explain` over the live
3
3
  // registry, tuned for an agent's token budget: terse names-first default, opt-in
4
- // 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";
4
+ // description search, drill-in `--detail`.
5
+ //
6
+ // It honors the global `--format`, and its default is TEXT in every case — deliberately
7
+ // NOT the TTY-aware default (`resolveFormat`: TOON off a TTY) that gen/verify/migrate take.
8
+ // Two reasons, and the second is the binding one: this command's text output is already the
9
+ // agent-tuned rendering the whole design is for, and every existing non-interactive caller
10
+ // pipes exactly that a TTY-aware default would silently change what all of them read, with
11
+ // no flag passed. So index.ts hands this command the RAW --format flag rather than the
12
+ // resolved `fmt`, and absent means text. (`gen` already declares its own `fmt = "text"`
13
+ // default, so a command-local default is not a new idea.)
14
+ //
15
+ // In a structured format the rule is stdout PURITY: exactly one document, nothing else. The
16
+ // legend, the "N of M shown" footer and the no-match hint are all TEXT rendering — a `| jq`
17
+ // dies on any of them — so the structured branch emits none of them and carries what they
18
+ // said as FIELDS instead.
19
+ //
20
+ // The `--json` flag this once advertised in its own --help, twice, is not coming back: the
21
+ // CLI rejects a bare `--json` before a command ever sees its args (index.ts, `formatAlias`),
22
+ // on purpose — one global spelling for all three formats.
23
+ import { composeRegistry, coreProviders, buildVocabularyCatalog } from "@metaobjectsdev/metadata";
24
+ import { forgeTypesProvider } from "@metaobjectsdev/sdk";
11
25
  import { log } from "../lib/log.js";
26
+ import { emitStructured, type OutputFormat } from "../lib/format.js";
12
27
 
13
28
  interface TypesFlags {
14
29
  query: string | null;
@@ -16,7 +31,6 @@ interface TypesFlags {
16
31
  kind: Set<"type" | "subtype" | "attr">;
17
32
  type: string | null; // scope to one top-level type
18
33
  detail: boolean;
19
- json: boolean;
20
34
  noHeaders: boolean;
21
35
  limit: number; // 0 = unlimited
22
36
  help: boolean;
@@ -28,7 +42,7 @@ const HELP = `meta types [QUERY] — search the metadata vocabulary without load
28
42
  meta types --all money # search names AND descriptions ("find by what it does")
29
43
  meta types --type field --kind subtype # all field subtypes (terse)
30
44
  meta types field.enum --detail # one construct: description + when-to-use + valid @attrs
31
- meta types --type origin --json # machine-readable subtree
45
+ meta types --type view --kind subtype # every registered view control
32
46
 
33
47
  QUERY is a case-insensitive substring, matched on the name (type.subType / @attr).
34
48
  Add --desc (or --all) to also match descriptions + when-to-use guidance.
@@ -38,26 +52,39 @@ Add --desc (or --all) to also match descriptions + when-to-use guidance.
38
52
  --kind <k> filter by category: type | subtype | attr (comma-list ok)
39
53
  --type <name> scope to one top-level type (e.g. --type field)
40
54
  --detail drill in: full description, when-to-use, and valid @attrs
41
- --json emit the matching registry subtree verbatim (stable-sorted)
42
55
  --limit <N> cap results (default 20; 0 = unlimited)
43
56
  --no-headers omit headers (parse-friendly)
57
+ --format <toon|json|text> Output format (global flag). Defaults to TEXT here, even
58
+ off a TTY — unlike gen/verify/migrate, whose default is TOON off a TTY.
44
59
 
45
60
  Default output is one terse line per match. Reach for metaobjects metadata
46
61
  (declare it, regenerate) instead of hand-writing data logic — this finds the
47
- construct.`;
62
+ construct.
63
+
64
+ --format toon / --format json emit ONE machine-readable document and nothing else. It
65
+ carries every match with its full record, so --limit, --detail and --no-headers — all
66
+ three TEXT display controls — do not change it. The terse line's [base] / [ts-only]
67
+ markers are the sharedRoot / tsOnly fields there, a closed-enum attr carries its
68
+ allowedValues, and no match is an empty matches list rather than a prose hint.`;
48
69
 
49
70
  function parse(args: string[]): TypesFlags {
50
71
  const f: TypesFlags = {
51
72
  query: null, desc: false, kind: new Set(), type: null,
52
- detail: false, json: false, noHeaders: false, limit: 20, help: false,
73
+ detail: false, noHeaders: false, limit: 20, help: false,
53
74
  };
54
75
  for (let i = 0; i < args.length; i++) {
55
76
  const a = args[i] as string;
56
77
  if (a === "--help" || a === "-h") f.help = true;
57
78
  else if (a === "--desc" || a === "--all") f.desc = true;
58
79
  else if (a === "--detail") f.detail = true;
59
- else if (a === "--json") f.json = true;
60
80
  else if (a === "--no-headers") f.noHeaders = true;
81
+ // `--format` is a GLOBAL flag: index.ts strips it (and its value) from argv before
82
+ // dispatch and passes the result in as `fmt`, so it never reaches here through the
83
+ // CLI. Accepted and ignored anyway, so that the flag this command's --help lists is
84
+ // one the parser accepts — the invariant types-command.test.ts holds it to, and the
85
+ // one the removed `--json` broke.
86
+ else if (a === "--format") i++;
87
+ else if (a.startsWith("--format=")) { /* value is inline; nothing to consume */ }
61
88
  else if (a === "--limit") f.limit = Math.max(0, Number(args[++i] ?? "20") || 0);
62
89
  else if (a === "--type") f.type = (args[++i] ?? "").toLowerCase() || null;
63
90
  else if (a === "--kind") {
@@ -78,43 +105,160 @@ interface Entry {
78
105
  description: string;
79
106
  whenToUse: string | undefined;
80
107
  attrNames: string[]; // attr-name hints (for subtype terse lines)
81
- raw: unknown; // the manifest node, for --json
108
+ /** Accepted on EVERY node, so no `--type` scope excludes it (#357). */
109
+ common: boolean;
110
+ /** Registered here but carved out of the cross-port manifest — TS-only vocabulary. */
111
+ tsOnly: boolean;
112
+ /** A type's shared root (`<type>.base`): its attrs apply to every subtype. */
113
+ sharedRoot: boolean;
114
+ raw: unknown; // the catalog node
115
+ }
116
+
117
+ /** Compact markers appended to a terse line, and the legend that explains them. */
118
+ const MARK_TS_ONLY = "[ts-only]";
119
+ const MARK_BASE = "[base]";
120
+ const LEGEND = ` ${MARK_BASE} the type's shared root — its @attrs apply to every subtype of that type\n` +
121
+ ` ${MARK_TS_ONLY} registered in TypeScript only; the cross-port metamodel contract does not carry it`;
122
+
123
+ function marks(e: Entry): string {
124
+ const m = [e.sharedRoot ? MARK_BASE : "", e.tsOnly ? MARK_TS_ONLY : ""].filter(Boolean);
125
+ return m.length > 0 ? ` ${m.join(" ")}` : "";
126
+ }
127
+
128
+ /** A catalog attr, as far as the payload needs to know. */
129
+ interface RawAttr {
130
+ name: string;
131
+ valueType: string | null;
132
+ isArray: boolean;
133
+ required: boolean;
134
+ allowedValues?: readonly string[];
135
+ description: string;
136
+ whenToUse?: string;
82
137
  }
83
138
 
84
- export async function typesCommand(args: string[]): Promise<number> {
139
+ function attrRecord(at: RawAttr): Record<string, unknown> {
140
+ return {
141
+ name: at.name,
142
+ valueType: at.valueType,
143
+ isArray: at.isArray,
144
+ required: at.required,
145
+ ...(at.allowedValues !== undefined ? { allowedValues: at.allowedValues } : {}),
146
+ description: at.description,
147
+ ...(at.whenToUse !== undefined ? { whenToUse: at.whenToUse } : {}),
148
+ };
149
+ }
150
+
151
+ function payloadMatch(e: Entry): Record<string, unknown> {
152
+ const out: Record<string, unknown> = {
153
+ kind: e.kind,
154
+ name: e.name,
155
+ // A common attr belongs to no type and no subtype; `common: true` is what says so,
156
+ // and null is the honest answer for both. The text rendering's "(any node)" owner is
157
+ // a label for a human reading a column, not a value to hand a program.
158
+ type: e.common || e.type === "" ? null : e.type,
159
+ owner: e.common ? null : e.owner,
160
+ common: e.common,
161
+ tsOnly: e.tsOnly,
162
+ sharedRoot: e.sharedRoot,
163
+ description: e.description,
164
+ ...(e.whenToUse !== undefined ? { whenToUse: e.whenToUse } : {}),
165
+ };
166
+ if (e.kind === "subtype") {
167
+ out.attrs = (e.raw as { attrs: RawAttr[] }).attrs.map(attrRecord);
168
+ } else {
169
+ const at = e.raw as RawAttr;
170
+ out.valueType = at.valueType;
171
+ out.isArray = at.isArray;
172
+ out.required = at.required;
173
+ if (at.allowedValues !== undefined) out.allowedValues = at.allowedValues;
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * The machine-readable answer: ONE document, every match, the whole record.
180
+ *
181
+ * Not a serialization of the text rendering. `--limit`, `--detail` and `--no-headers` are
182
+ * TEXT display controls — the global --help already promises a structured payload is never
183
+ * truncated — so none of them reaches here and the payload always carries everything.
184
+ *
185
+ * `metamodelVersion` is included because it is the number that says whether this answer
186
+ * applies to a given model at all: the vocabulary is a contract with a version, and a
187
+ * consumer caching this document needs to know which one it captured.
188
+ */
189
+ function buildPayload(f: TypesFlags, matches: Entry[], metamodelVersion: string): unknown {
190
+ return {
191
+ metamodelVersion,
192
+ query: f.query,
193
+ filters: {
194
+ type: f.type,
195
+ kind: [...f.kind].sort(),
196
+ searchDescriptions: f.desc,
197
+ },
198
+ total: matches.length,
199
+ matches: matches.map(payloadMatch),
200
+ };
201
+ }
202
+
203
+ export async function typesCommand(args: string[], fmt: OutputFormat = "text"): Promise<number> {
85
204
  let f: TypesFlags;
86
205
  try {
87
206
  f = parse(args);
88
207
  } catch (err) {
89
- log.error((err as Error).message);
208
+ const msg = (err as Error).message;
209
+ log.error(msg);
210
+ // A structured caller gets a structured refusal — exiting 2 with an EMPTY stdout is
211
+ // the same silence a `| jq` cannot tell from "no results". Mirrors verify.ts.
212
+ emitStructured({ error: msg, hint: "run `meta types --help` for the accepted flags" }, fmt);
90
213
  return 2;
91
214
  }
92
215
  if (f.help) {
216
+ // Help is prose by definition — rendering it as a JSON string helps nobody, and a
217
+ // consumer asking for help is a human either way.
93
218
  log.info(HELP);
94
219
  return 0;
95
220
  }
96
221
 
97
- const registry = new TypeRegistry();
98
- registerCoreTypes(registry);
99
- registerForgeTypes(registry);
100
- const manifest = buildRegistryManifest(registry);
222
+ // #357 — COMPOSE the registry, never `registerCoreTypes` alone. The db, ui-web and
223
+ // documentation providers register attrs onto types the core provider declares, so a
224
+ // partially-composed registry reports a type that exists with most of its attributes
225
+ // missing: `field.string` came back with 6 attrs instead of 16 (no @column, @filterable,
226
+ // @sortable, @dbColumnType), `view.textarea` with none (no @rows), and the eight
227
+ // documentation commonAttrs — @title among them — were absent entirely. This is the same
228
+ // provider set the loader composes, so what this prints is what the loader accepts.
229
+ const registry = composeRegistry([...coreProviders, forgeTypesProvider], { validate: true });
230
+ const catalog = buildVocabularyCatalog(registry);
101
231
 
102
- // Flatten the manifest into searchable entries.
232
+ // Flatten the catalog into searchable entries.
103
233
  const entries: Entry[] = [];
104
- for (const mt of manifest.types) {
234
+ for (const mt of catalog.types) {
105
235
  const tsName = `${mt.type}.${mt.subType}`;
106
236
  entries.push({
107
237
  kind: "subtype", name: tsName, owner: tsName, type: mt.type,
108
238
  description: mt.description, whenToUse: mt.whenToUse,
109
- attrNames: mt.attrs.map((a) => `@${a.name}`), raw: mt,
239
+ attrNames: mt.attrs.map((a) => `@${a.name}`),
240
+ common: false, tsOnly: !mt.crossPort, sharedRoot: mt.sharedRoot, raw: mt,
110
241
  });
111
242
  for (const at of mt.attrs) {
112
243
  entries.push({
113
244
  kind: "attr", name: `${tsName} @${at.name}`, owner: tsName, type: mt.type,
114
- description: at.description, whenToUse: at.whenToUse, attrNames: [], raw: at,
245
+ description: at.description, whenToUse: at.whenToUse, attrNames: [],
246
+ common: false, tsOnly: !mt.crossPort, sharedRoot: false, raw: at,
115
247
  });
116
248
  }
117
249
  }
250
+ // #357 — the attrs every node accepts. These were absent from the search entirely, so
251
+ // `meta types title` reported nothing for `@title` — the registered attr an author is
252
+ // supposed to find INSTEAD of asking for a new one (that omission is exactly how #353
253
+ // became a request to register `@label`). They belong to no single type, so no `--type`
254
+ // scope excludes them.
255
+ for (const at of catalog.commonAttrs) {
256
+ entries.push({
257
+ kind: "attr", name: `@${at.name}`, owner: "(any node)", type: "",
258
+ description: at.description, whenToUse: at.whenToUse, attrNames: [],
259
+ common: true, tsOnly: false, sharedRoot: false, raw: at,
260
+ });
261
+ }
118
262
 
119
263
  const q = (f.query ?? "").toLowerCase();
120
264
  const wantKind = (e: Entry) => f.kind.size === 0
@@ -122,7 +266,8 @@ export async function typesCommand(args: string[]): Promise<number> {
122
266
  : (f.kind.has("attr") && e.kind === "attr") ||
123
267
  ((f.kind.has("subtype") || f.kind.has("type")) && e.kind === "subtype");
124
268
  const matches = entries.filter((e) => {
125
- if (f.type && e.type !== f.type) return false;
269
+ // A common attr is accepted on every type, so `--type field` must not hide it.
270
+ if (f.type && e.type !== f.type && !e.common) return false;
126
271
  if (!wantKind(e)) return false;
127
272
  if (!q) return true;
128
273
  if (e.name.toLowerCase().includes(q)) return true;
@@ -133,6 +278,13 @@ export async function typesCommand(args: string[]): Promise<number> {
133
278
  // Stable sort by (type, name).
134
279
  matches.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
135
280
 
281
+ // Structured output branches BEFORE the no-match hint below, deliberately: an empty
282
+ // result must still be a valid document (`"matches": []`), not a sentence on stdout.
283
+ if (fmt !== "text") {
284
+ emitStructured(buildPayload(f, matches, catalog.metamodelVersion), fmt);
285
+ return 0;
286
+ }
287
+
136
288
  if (matches.length === 0) {
137
289
  log.info(q ? `No vocabulary matches "${f.query}". Try --all to search descriptions, or drop --kind/--type filters.`
138
290
  : "No matching vocabulary.");
@@ -142,16 +294,14 @@ export async function typesCommand(args: string[]): Promise<number> {
142
294
  const total = matches.length;
143
295
  const shown = f.limit > 0 ? matches.slice(0, f.limit) : matches;
144
296
 
145
- if (f.json) {
146
- log.info(JSON.stringify(shown.map((e) => e.raw), null, 2));
147
- return 0;
148
- }
149
-
150
297
  if (f.detail) {
151
298
  for (const e of shown) {
152
- const head = e.kind === "subtype" ? e.name : e.name;
153
- log.info(`\n${head} (${e.kind})`);
299
+ log.info(`\n${e.name} (${e.kind})`);
154
300
  if (e.description) log.info(` ${e.description}`);
301
+ if (e.sharedRoot)
302
+ log.info(` shared root: @attrs registered here apply to every ${e.type}.* subtype.`);
303
+ if (e.tsOnly)
304
+ log.info(" TypeScript-only: registered here, but not part of the cross-port metamodel contract.");
155
305
  if (e.whenToUse) log.info(` → reach for it when: ${e.whenToUse}`);
156
306
  if (e.kind === "subtype") {
157
307
  const mt = e.raw as { attrs: { name: string; valueType: string | null; required: boolean; description: string }[] };
@@ -167,12 +317,13 @@ export async function typesCommand(args: string[]): Promise<number> {
167
317
  for (const e of shown) {
168
318
  if (e.kind === "subtype") {
169
319
  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}`);
320
+ log.info(`${e.name.padEnd(28)} ${oneLine(e.description)}${hint}${marks(e)}`);
171
321
  } else {
172
- log.info(`${e.name.padEnd(28)} ${oneLine(e.description)}`);
322
+ log.info(`${e.name.padEnd(28)} ${oneLine(e.description)}${marks(e)}`);
173
323
  }
174
324
  }
175
325
  }
326
+ if (!f.noHeaders && !f.detail && shown.some((e) => marks(e) !== "")) log.info(`\n${LEGEND}`);
176
327
  if (!f.noHeaders && shown.length < total)
177
328
  log.info(`\n${shown.length} of ${total} shown — narrow with QUERY/--type/--kind or raise --limit.`);
178
329
  else if (!f.noHeaders)