metaobjects 0.9.0__py3-none-any.whl

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 (181) hide show
  1. metaobjects/__init__.py +75 -0
  2. metaobjects/agent_context/__init__.py +55 -0
  3. metaobjects/agent_context/_content/README.md +14 -0
  4. metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
  5. metaobjects/agent_context/_content/servers/java.meta.json +5 -0
  6. metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
  7. metaobjects/agent_context/_content/servers/python.meta.json +5 -0
  8. metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
  9. metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
  10. metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
  11. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
  12. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
  13. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
  14. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
  15. metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
  16. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
  17. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
  18. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
  19. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
  20. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
  21. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
  22. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
  23. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
  24. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
  25. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
  26. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
  27. metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
  28. metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
  29. metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
  30. metaobjects/agent_context/assemble.py +133 -0
  31. metaobjects/agent_context/content_root.py +54 -0
  32. metaobjects/agent_context/scaffold.py +191 -0
  33. metaobjects/agent_context/types.py +44 -0
  34. metaobjects/attr_class_map.py +23 -0
  35. metaobjects/cli.py +696 -0
  36. metaobjects/codegen/__init__.py +0 -0
  37. metaobjects/codegen/config.py +11 -0
  38. metaobjects/codegen/constants.py +13 -0
  39. metaobjects/codegen/extract_delegate_emitter.py +384 -0
  40. metaobjects/codegen/extract_schema_emitter.py +139 -0
  41. metaobjects/codegen/format.py +31 -0
  42. metaobjects/codegen/fr010_field_mapping.py +220 -0
  43. metaobjects/codegen/generator.py +62 -0
  44. metaobjects/codegen/generator_registry.py +163 -0
  45. metaobjects/codegen/generators/__init__.py +0 -0
  46. metaobjects/codegen/generators/entity_model.py +263 -0
  47. metaobjects/codegen/generators/extractor_generator.py +317 -0
  48. metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
  49. metaobjects/codegen/generators/m2m_codegen.py +192 -0
  50. metaobjects/codegen/generators/output_parser_generator.py +272 -0
  51. metaobjects/codegen/generators/output_prompt_generator.py +192 -0
  52. metaobjects/codegen/generators/payload_vo_generator.py +672 -0
  53. metaobjects/codegen/generators/render_helper_generator.py +451 -0
  54. metaobjects/codegen/generators/router_generator.py +635 -0
  55. metaobjects/codegen/generators/template_generator.py +70 -0
  56. metaobjects/codegen/generators/tph_plan.py +120 -0
  57. metaobjects/codegen/generators/trace_helper_generator.py +336 -0
  58. metaobjects/codegen/instance_artifacts.py +15 -0
  59. metaobjects/codegen/output_format_spec_emitter.py +79 -0
  60. metaobjects/codegen/overwrite_policy.py +27 -0
  61. metaobjects/codegen/runner.py +110 -0
  62. metaobjects/codegen/runtime/__init__.py +6 -0
  63. metaobjects/codegen/runtime/filter_parser.py +193 -0
  64. metaobjects/codegen/type_map.py +84 -0
  65. metaobjects/core_types.py +809 -0
  66. metaobjects/datatype.py +19 -0
  67. metaobjects/documentation/__init__.py +28 -0
  68. metaobjects/documentation/doc_constants.py +20 -0
  69. metaobjects/documentation/doc_provider.py +20 -0
  70. metaobjects/documentation/doc_schema.py +24 -0
  71. metaobjects/errors.py +124 -0
  72. metaobjects/loader/__init__.py +0 -0
  73. metaobjects/loader/merge.py +287 -0
  74. metaobjects/loader/meta_data_loader.py +245 -0
  75. metaobjects/loader/sources/__init__.py +24 -0
  76. metaobjects/loader/sources/directory_source.py +50 -0
  77. metaobjects/loader/sources/file_source.py +41 -0
  78. metaobjects/loader/sources/meta_data_source.py +67 -0
  79. metaobjects/loader/sources/uri_source.py +56 -0
  80. metaobjects/loader/validate_discriminator.py +181 -0
  81. metaobjects/loader/validate_field_readonly.py +146 -0
  82. metaobjects/loader/validate_source_parameter_ref.py +159 -0
  83. metaobjects/loader/validate_source_physical_names.py +140 -0
  84. metaobjects/loader/validation_passes.py +1513 -0
  85. metaobjects/meta/__init__.py +1 -0
  86. metaobjects/meta/core/__init__.py +0 -0
  87. metaobjects/meta/core/attr/__init__.py +0 -0
  88. metaobjects/meta/core/attr/attr_constants.py +31 -0
  89. metaobjects/meta/core/attr/meta_attr.py +136 -0
  90. metaobjects/meta/core/field/__init__.py +0 -0
  91. metaobjects/meta/core/field/field_constants.py +105 -0
  92. metaobjects/meta/core/field/meta_field.py +76 -0
  93. metaobjects/meta/core/identity/__init__.py +0 -0
  94. metaobjects/meta/core/identity/identity_constants.py +19 -0
  95. metaobjects/meta/core/identity/meta_identity.py +8 -0
  96. metaobjects/meta/core/object/__init__.py +0 -0
  97. metaobjects/meta/core/object/meta_object.py +65 -0
  98. metaobjects/meta/core/object/meta_object_aware.py +43 -0
  99. metaobjects/meta/core/object/object_class_registry.py +56 -0
  100. metaobjects/meta/core/object/object_constants.py +13 -0
  101. metaobjects/meta/core/object/object_extract.py +400 -0
  102. metaobjects/meta/core/object/value_object.py +70 -0
  103. metaobjects/meta/core/relationship/__init__.py +0 -0
  104. metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
  105. metaobjects/meta/core/relationship/meta_relationship.py +54 -0
  106. metaobjects/meta/core/relationship/relationship_constants.py +51 -0
  107. metaobjects/meta/core/validator/__init__.py +0 -0
  108. metaobjects/meta/core/validator/validator_constants.py +18 -0
  109. metaobjects/meta/meta_data.py +206 -0
  110. metaobjects/meta/meta_root.py +8 -0
  111. metaobjects/meta/persistence/__init__.py +0 -0
  112. metaobjects/meta/persistence/db/__init__.py +1 -0
  113. metaobjects/meta/persistence/db/db_constants.py +41 -0
  114. metaobjects/meta/persistence/db/db_provider.py +60 -0
  115. metaobjects/meta/persistence/origin/__init__.py +0 -0
  116. metaobjects/meta/persistence/origin/meta_origin.py +8 -0
  117. metaobjects/meta/persistence/origin/origin_constants.py +20 -0
  118. metaobjects/meta/persistence/source/__init__.py +0 -0
  119. metaobjects/meta/persistence/source/meta_source.py +137 -0
  120. metaobjects/meta/persistence/source/source_constants.py +115 -0
  121. metaobjects/meta/presentation/__init__.py +0 -0
  122. metaobjects/meta/presentation/layout/__init__.py +0 -0
  123. metaobjects/meta/presentation/layout/layout_constants.py +13 -0
  124. metaobjects/meta/presentation/layout/meta_layout.py +8 -0
  125. metaobjects/meta/presentation/view/__init__.py +0 -0
  126. metaobjects/meta/presentation/view/meta_view.py +8 -0
  127. metaobjects/meta/presentation/view/view_constants.py +22 -0
  128. metaobjects/meta/template/__init__.py +0 -0
  129. metaobjects/meta/template/meta_template.py +46 -0
  130. metaobjects/meta/template/template_constants.py +112 -0
  131. metaobjects/meta/template/template_provider.py +43 -0
  132. metaobjects/parser.py +380 -0
  133. metaobjects/parser_yaml.py +82 -0
  134. metaobjects/provider.py +111 -0
  135. metaobjects/py.typed +0 -0
  136. metaobjects/registry.py +210 -0
  137. metaobjects/registry_manifest.py +223 -0
  138. metaobjects/render/__init__.py +74 -0
  139. metaobjects/render/email_document.py +14 -0
  140. metaobjects/render/escapers.py +109 -0
  141. metaobjects/render/extract/__init__.py +59 -0
  142. metaobjects/render/extract/coerce.py +279 -0
  143. metaobjects/render/extract/extract.py +211 -0
  144. metaobjects/render/extract/extract_map.py +61 -0
  145. metaobjects/render/extract/json_forgiving_reader.py +203 -0
  146. metaobjects/render/extract/locate.py +65 -0
  147. metaobjects/render/extract/normalize.py +96 -0
  148. metaobjects/render/extract/strip.py +20 -0
  149. metaobjects/render/extract/types.py +332 -0
  150. metaobjects/render/extract/xml_forgiving_reader.py +162 -0
  151. metaobjects/render/filesystem_provider.py +51 -0
  152. metaobjects/render/prompt/__init__.py +32 -0
  153. metaobjects/render/prompt/output_format_renderer.py +340 -0
  154. metaobjects/render/prompt/output_format_spec.py +28 -0
  155. metaobjects/render/prompt/prompt_field.py +29 -0
  156. metaobjects/render/prompt/prompt_overrides.py +29 -0
  157. metaobjects/render/prompt/prompt_style.py +38 -0
  158. metaobjects/render/renderer.py +358 -0
  159. metaobjects/render/verify.py +266 -0
  160. metaobjects/runtime/__init__.py +39 -0
  161. metaobjects/runtime/llm_recorder.py +210 -0
  162. metaobjects/runtime/n2m_resolver.py +155 -0
  163. metaobjects/runtime/object_manager.py +715 -0
  164. metaobjects/runtime/tph.py +50 -0
  165. metaobjects/serializer_json.py +172 -0
  166. metaobjects/shared/__init__.py +0 -0
  167. metaobjects/shared/base_types.py +16 -0
  168. metaobjects/shared/separators.py +4 -0
  169. metaobjects/shared/structural.py +9 -0
  170. metaobjects/source/__init__.py +79 -0
  171. metaobjects/source/error_source.py +266 -0
  172. metaobjects/source/json_path.py +106 -0
  173. metaobjects/source/semantic_diff.py +98 -0
  174. metaobjects/source/yaml_positions.py +174 -0
  175. metaobjects/super_resolve.py +128 -0
  176. metaobjects/yaml_desugar.py +481 -0
  177. metaobjects-0.9.0.dist-info/METADATA +97 -0
  178. metaobjects-0.9.0.dist-info/RECORD +181 -0
  179. metaobjects-0.9.0.dist-info/WHEEL +4 -0
  180. metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
  181. metaobjects-0.9.0.dist-info/licenses/LICENSE +189 -0
metaobjects/cli.py ADDED
@@ -0,0 +1,696 @@
1
+ """The ``metaobjects`` console-script — codegen `gen` + drift `verify` (SP-E Unit 2).
2
+
3
+ Two subcommands:
4
+
5
+ metaobjects gen <metadataDir> --out <dir> [--package <pkg>]
6
+ Load metadata, run the Python codegen generator suite, and write files
7
+ under ``--out`` (guarded by the @generated header). Prints each written
8
+ file. Non-zero exit on a load error.
9
+
10
+ metaobjects verify <metadataDir> [--codegen] [--templates] [--db URL] ...
11
+ Drift gate with explicit subverbs (ADR-0021 D2 — one verify vocabulary
12
+ across ports):
13
+
14
+ --codegen regenerate into a temp dir + diff against the committed
15
+ ``--out`` tree (Python's historical default behavior).
16
+ --templates template/prompt ``{{field}}`` ↔ payload-VO drift — the
17
+ render ``verify()`` gate — resolving each ``template.*``
18
+ node's refs via a filesystem provider rooted at
19
+ ``--templates-root``.
20
+ --db URL REJECTED in the Python port (exit 2): schema verify is the
21
+ migrate engine (ADR-0015), not this CLI.
22
+
23
+ Combining flags runs each requested mode and aggregates the exit code
24
+ (non-zero if ANY mode drifts). A bare ``verify`` keeps the historical
25
+ default = ``--codegen`` (back-compat) and prints a one-line note that the
26
+ explicit subverbs exist.
27
+
28
+ Named ``metaobjects``, NOT ``meta``: ``meta`` is the Node schema CLI. This CLI
29
+ intentionally has NO ``migrate`` subcommand — schema is owned by the Node
30
+ toolchain (ADR-0015). The ``--codegen`` mode shares the exact same generation
31
+ code path as ``gen`` (verify = gen-to-temp + diff), so drift can never be a
32
+ generator-wiring divergence between the two commands.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import sys
40
+ import tempfile
41
+ from pathlib import Path
42
+
43
+ from metaobjects import MetaDataLoader
44
+ from metaobjects.agent_context import (
45
+ AGENT_CONTEXT_MANIFEST_PATH,
46
+ Manifest,
47
+ agent_context_staleness,
48
+ assemble,
49
+ installed_metaobjects_version,
50
+ make_stack,
51
+ plan_scaffold,
52
+ resolve_agent_context_root,
53
+ )
54
+ from metaobjects.meta.meta_data import MetaData
55
+ from metaobjects.codegen.config import GenConfig
56
+ from metaobjects.codegen.generator import Generator
57
+ from metaobjects.codegen.generators.entity_model import entity_model
58
+ from metaobjects.codegen.generators.extractor_generator import extractor_generator
59
+ from metaobjects.codegen.generators.filter_allowlist_generator import (
60
+ filter_allowlist_generator,
61
+ )
62
+ from metaobjects.codegen.generators.output_parser_generator import (
63
+ output_parser_generator,
64
+ )
65
+ from metaobjects.codegen.generators.output_prompt_generator import (
66
+ output_prompt_generator,
67
+ )
68
+ from metaobjects.codegen.generators.payload_vo_generator import payload_vo_generator
69
+ from metaobjects.codegen.generators.router_generator import router_generator
70
+ from metaobjects.codegen.generator_registry import (
71
+ GENERATOR_REGISTRY,
72
+ get_generator,
73
+ list_generators,
74
+ )
75
+ from metaobjects.codegen.runner import run_gen
76
+ from metaobjects.codegen.generators.render_helper_generator import (
77
+ _derive_payload_field_tree,
78
+ _resolve_payload_vo,
79
+ )
80
+ from metaobjects.meta.template import template_constants as tc
81
+ from metaobjects.render.filesystem_provider import FilesystemProvider
82
+ from metaobjects.render.verify import (
83
+ ERR_REQUIRED_SLOT_UNUSED,
84
+ PayloadField,
85
+ verify as render_verify,
86
+ )
87
+ from metaobjects.shared.base_types import TYPE_TEMPLATE
88
+
89
+
90
+ def _default_generators() -> list[Generator]:
91
+ """The default codegen suite — the no-config generators every project gets.
92
+
93
+ ``template_generator`` is excluded: it requires a caller-supplied text
94
+ provider + Mustache template and is not a zero-config per-entity emitter.
95
+ """
96
+ return [
97
+ entity_model(),
98
+ router_generator(),
99
+ filter_allowlist_generator(),
100
+ payload_vo_generator(),
101
+ output_parser_generator(),
102
+ output_prompt_generator(),
103
+ extractor_generator(),
104
+ ]
105
+
106
+
107
+ def _load_root(metadata_dir: str) -> tuple[MetaData | None, list[str]]:
108
+ """Load metadata; return ``(root, error_messages)``. ``root`` is None on error."""
109
+ result = MetaDataLoader.from_directory(metadata_dir)
110
+ if result.errors:
111
+ msgs = [f"{e.code}: {e.message}" for e in result.errors]
112
+ return None, msgs
113
+ return result.root, []
114
+
115
+
116
+ def _resolve_generators(names: str) -> tuple[list[Generator], list[str]]:
117
+ """Resolve a comma-separated list of STABLE generator names via the registry.
118
+
119
+ Returns ``(generators, errors)``. An unknown name produces a clear error and
120
+ no generators (so the caller can fail with exit code != 0).
121
+ """
122
+ requested = [n.strip() for n in names.split(",") if n.strip()]
123
+ gens: list[Generator] = []
124
+ errors: list[str] = []
125
+ for n in requested:
126
+ entry = get_generator(n)
127
+ if entry is None:
128
+ known = ", ".join(sorted(GENERATOR_REGISTRY))
129
+ errors.append(f"unknown generator {n!r}; known: {known}")
130
+ continue
131
+ gens.append(entry.factory())
132
+ if not errors and not gens:
133
+ errors.append("no generators selected (empty --generators list)")
134
+ return gens, errors
135
+
136
+
137
+ def _generate(
138
+ metadata_dir: str, out_dir: str, generators: list[Generator] | None = None
139
+ ) -> tuple[list[str], list[str]]:
140
+ """Run the generator suite into ``out_dir``.
141
+
142
+ ``generators`` defaults to the zero-config default suite; pass a registry-
143
+ resolved subset for ``--generators``. Returns ``(written_paths, errors)``. On
144
+ a load error, ``errors`` is non-empty and no files are written.
145
+ """
146
+ root, errors = _load_root(metadata_dir)
147
+ if root is None:
148
+ return [], errors
149
+ config = GenConfig(out_dir=out_dir)
150
+ suite = generators if generators is not None else _default_generators()
151
+ result = run_gen(config, root, generators=suite)
152
+ written = [path for path, status in result.files if status != "refused"]
153
+ return written, []
154
+
155
+
156
+ def _cmd_list(_args: argparse.Namespace) -> int:
157
+ """Print each registered generator ``<stable-name> — <description>`` and exit 0.
158
+
159
+ Does NOT run codegen — pure discoverability (ADR-0021 D3).
160
+ """
161
+ for entry in list_generators():
162
+ print(f"{entry.name} — {entry.description}")
163
+ return 0
164
+
165
+
166
+ def _warn_if_agent_context_stale() -> None:
167
+ """Print ONE advisory line to stderr if the scaffolded agent context is stale.
168
+
169
+ Reads ``<cwd>/.metaobjects/.agent-context.json`` (if present), compares its
170
+ stamped ``generatedBy`` to the installed version, and nudges a re-scaffold on
171
+ any drift. Advisory only: never raises, never changes the exit code, never
172
+ writes — a missing or corrupt manifest is silently ignored.
173
+ """
174
+ try:
175
+ manifest_path = Path.cwd() / AGENT_CONTEXT_MANIFEST_PATH
176
+ if not manifest_path.is_file():
177
+ return
178
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
179
+ if not isinstance(manifest, dict):
180
+ return
181
+ msg = agent_context_staleness(manifest, installed_metaobjects_version())
182
+ if msg is not None:
183
+ print(msg, file=sys.stderr)
184
+ except Exception: # noqa: BLE001 — advisory; any failure is silently ignored
185
+ return
186
+
187
+
188
+ def _cmd_gen(args: argparse.Namespace) -> int:
189
+ # `--list` is a pure discoverability path: print the registry and exit, no codegen.
190
+ if getattr(args, "list", False):
191
+ return _cmd_list(args)
192
+
193
+ _warn_if_agent_context_stale()
194
+
195
+ if args.metadata_dir is None or args.out is None:
196
+ print(
197
+ "error: gen requires <metadata_dir> and --out (or use --list).",
198
+ file=sys.stderr,
199
+ )
200
+ return 2
201
+
202
+ generators: list[Generator] | None = None
203
+ if args.generators:
204
+ generators, gen_errors = _resolve_generators(args.generators)
205
+ if gen_errors:
206
+ print("error: invalid --generators selection:", file=sys.stderr)
207
+ for msg in gen_errors:
208
+ print(f" {msg}", file=sys.stderr)
209
+ return 1
210
+
211
+ written, errors = _generate(args.metadata_dir, args.out, generators)
212
+ if errors:
213
+ print("error: failed to load metadata:", file=sys.stderr)
214
+ for msg in errors:
215
+ print(f" {msg}", file=sys.stderr)
216
+ return 1
217
+ for path in written:
218
+ print(path)
219
+ print(f"metaobjects gen: wrote {len(written)} file(s) to {args.out}")
220
+ return 0
221
+
222
+
223
+ def _relative_set(root: Path) -> dict[str, str]:
224
+ """Map every ``*.py`` file under ``root`` to its content, keyed by rel path.
225
+
226
+ Scoped to ``*.py`` because the Python codegen suite emits only Python sources;
227
+ if a generator ever emits a non-``.py`` artifact, broaden this glob so ``verify``
228
+ drift-checks it too.
229
+ """
230
+ files: dict[str, str] = {}
231
+ if root.exists():
232
+ for p in sorted(root.rglob("*.py")):
233
+ files[str(p.relative_to(root))] = p.read_text()
234
+ return files
235
+
236
+
237
+ def _verify_codegen(args: argparse.Namespace) -> int:
238
+ """``verify --codegen`` — regenerate to a temp dir + diff vs committed ``--out``.
239
+
240
+ Python's historical ``verify`` behavior, unchanged. Reuses the exact ``gen``
241
+ code path (gen-to-temp + diff), so drift can never be a generator-wiring
242
+ divergence between the two commands.
243
+ """
244
+ if args.out is None:
245
+ print(
246
+ "error: verify --codegen requires --out (the committed output dir).",
247
+ file=sys.stderr,
248
+ )
249
+ return 2
250
+
251
+ # Reuse the exact gen code path — regenerate into a throwaway temp dir.
252
+ with tempfile.TemporaryDirectory() as tmp:
253
+ written, errors = _generate(args.metadata_dir, tmp)
254
+ if errors:
255
+ print("error: failed to load metadata:", file=sys.stderr)
256
+ for msg in errors:
257
+ print(f" {msg}", file=sys.stderr)
258
+ return 1
259
+
260
+ expected = _relative_set(Path(tmp))
261
+ committed = _relative_set(Path(args.out))
262
+
263
+ changed = sorted(
264
+ k for k in expected if k in committed and expected[k] != committed[k]
265
+ )
266
+ missing = sorted(k for k in expected if k not in committed) # not yet committed
267
+ extra = sorted(k for k in committed if k not in expected) # stale committed file
268
+
269
+ if not changed and not missing and not extra:
270
+ print(f"metaobjects verify: in sync ({len(expected)} file(s)).")
271
+ return 0
272
+
273
+ print("error: generated code is out of sync with metadata.", file=sys.stderr)
274
+ for k in changed:
275
+ print(f" drifted: {k}", file=sys.stderr)
276
+ for k in missing:
277
+ print(f" missing: {k}", file=sys.stderr)
278
+ for k in extra:
279
+ print(f" extra: {k}", file=sys.stderr)
280
+ print(
281
+ "regenerate (metaobjects gen) and commit the result.",
282
+ file=sys.stderr,
283
+ )
284
+ return 1
285
+
286
+
287
+ #: The text-ref attrs a ``template.*`` node may carry. Each is resolved through
288
+ #: the filesystem provider and run through the render ``verify()`` gate. A prompt
289
+ #: / document uses ``@textRef``; an email uses subject / html / (optional) text
290
+ #: part-refs. We check whichever are declared (matching the render-helper gate).
291
+ _TEMPLATE_TEXT_REF_ATTRS = (
292
+ tc.TEMPLATE_ATTR_TEXT_REF,
293
+ tc.TEMPLATE_ATTR_SUBJECT_REF,
294
+ tc.TEMPLATE_ATTR_HTML_BODY_REF,
295
+ tc.TEMPLATE_ATTR_TEXT_BODY_REF,
296
+ )
297
+
298
+
299
+ def _verify_templates(args: argparse.Namespace) -> int:
300
+ """``verify --templates`` — the template/prompt ``{{field}}`` ↔ payload drift gate.
301
+
302
+ For each ``template.*`` node: resolve its ``@payloadRef`` to an
303
+ ``object.value`` + derive its field tree (reusing the render-helper
304
+ generator's derivation — nested ``@objectRef`` resolved by short-name,
305
+ cycle-guarded), resolve each declared text-ref through a
306
+ :class:`FilesystemProvider` rooted at ``--templates-root``, and run the render
307
+ ``verify()`` engine ({{field}} ↔ payload-field drift). Any drift — an
308
+ unresolvable ref or a non-warning ``verify`` error — is reported and fails
309
+ (exit 1). Clean → 0. Reuses the render engine + field-tree walk; nothing is
310
+ reimplemented here.
311
+ """
312
+ template_root = getattr(args, "templates_root", None)
313
+ if not template_root:
314
+ print(
315
+ "error: verify --templates requires --templates-root (the on-disk "
316
+ "template/prompt dir).",
317
+ file=sys.stderr,
318
+ )
319
+ return 2
320
+
321
+ root, errors = _load_root(args.metadata_dir)
322
+ if root is None:
323
+ print("error: failed to load metadata:", file=sys.stderr)
324
+ for msg in errors:
325
+ print(f" {msg}", file=sys.stderr)
326
+ return 1
327
+
328
+ provider = FilesystemProvider(template_root)
329
+ # Toolcall templates have no renderable text body (the body IS the schema) —
330
+ # skip them; there is no {{field}} drift to check.
331
+ templates = [
332
+ c
333
+ for c in root.own_children()
334
+ if c.type == TYPE_TEMPLATE and c.sub_type != tc.TEMPLATE_SUBTYPE_TOOLCALL
335
+ ]
336
+
337
+ if not templates:
338
+ print("metaobjects verify --templates: no template.* nodes found.")
339
+ return 0
340
+
341
+ error_count = 0
342
+ checked = 0
343
+ for tmpl in sorted(templates, key=lambda c: c.name):
344
+ payload_ref = tmpl.attr(tc.TEMPLATE_ATTR_PAYLOAD_REF)
345
+ if not isinstance(payload_ref, str) or not payload_ref:
346
+ print(f"error: [{tmpl.name}] missing @payloadRef.", file=sys.stderr)
347
+ error_count += 1
348
+ continue
349
+ vo = _resolve_payload_vo(root, payload_ref)
350
+ if vo is None:
351
+ print(
352
+ f"error: [{tmpl.name}] @payloadRef '{payload_ref}' did not "
353
+ "resolve to an object.value.",
354
+ file=sys.stderr,
355
+ )
356
+ error_count += 1
357
+ continue
358
+
359
+ fields: list[PayloadField] = _derive_payload_field_tree(root, vo, frozenset())
360
+
361
+ refs = [
362
+ val
363
+ for a in _TEMPLATE_TEXT_REF_ATTRS
364
+ if isinstance(val := tmpl.attr(a), str) and val
365
+ ]
366
+ if not refs:
367
+ print(
368
+ f"error: [{tmpl.name}] declares no resolvable text-ref "
369
+ "(@textRef / @subjectRef / @htmlBodyRef / @textBodyRef).",
370
+ file=sys.stderr,
371
+ )
372
+ error_count += 1
373
+ continue
374
+
375
+ for ref in refs:
376
+ text = provider.resolve(ref)
377
+ if text is None:
378
+ print(
379
+ f"error: [{tmpl.name}] ref '{ref}' did not resolve under "
380
+ f"{template_root} (missing template).",
381
+ file=sys.stderr,
382
+ )
383
+ error_count += 1
384
+ continue
385
+ checked += 1
386
+ for e in render_verify(text, fields, provider=provider):
387
+ if e.code == ERR_REQUIRED_SLOT_UNUSED:
388
+ continue # warning, not drift
389
+ print(
390
+ f"error: [{tmpl.name}] ref '{ref}' — {e.code}: "
391
+ f"{{{{{e.path}}}}} not on payload VO '{payload_ref}'.",
392
+ file=sys.stderr,
393
+ )
394
+ error_count += 1
395
+
396
+ if error_count > 0:
397
+ print(
398
+ f"metaobjects verify --templates: {error_count} drift error(s).",
399
+ file=sys.stderr,
400
+ )
401
+ return 1
402
+ print(f"metaobjects verify --templates: {checked} template ref(s) clean.")
403
+ return 0
404
+
405
+
406
+ def _verify_db(_args: argparse.Namespace) -> int:
407
+ """``verify --db`` — REJECTED in the Python port (ADR-0021 D2).
408
+
409
+ Schema drift is owned by the migrate engine (ADR-0015), not this codegen CLI.
410
+ """
411
+ print(
412
+ "error: verify --db is not supported in the Python port; schema verify "
413
+ "is the migrate engine (the Node `meta` CLI, ADR-0015).",
414
+ file=sys.stderr,
415
+ )
416
+ return 2
417
+
418
+
419
+ def _cmd_verify(args: argparse.Namespace) -> int:
420
+ """Subverb dispatch (ADR-0021 D2). Run each requested mode; aggregate exit =
421
+ max (non-zero if ANY mode drifts). Bare ``verify`` (no subverb) keeps the
422
+ historical default = ``--codegen`` + a one-line note advertising the subverbs.
423
+ """
424
+ _warn_if_agent_context_stale()
425
+
426
+ run_db = args.db is not None
427
+ run_templates = bool(args.templates)
428
+ run_codegen = bool(args.codegen)
429
+ any_explicit = run_db or run_templates or run_codegen
430
+
431
+ if not any_explicit:
432
+ print(
433
+ "metaobjects verify — running --codegen (default). Explicit "
434
+ "subverbs: --codegen (codegen drift), --templates (template/prompt "
435
+ "drift), --db (schema drift — not supported in Python).",
436
+ file=sys.stderr,
437
+ )
438
+ run_codegen = True
439
+
440
+ exit_code = 0
441
+ if run_db:
442
+ exit_code = max(exit_code, _verify_db(args))
443
+ if run_codegen:
444
+ exit_code = max(exit_code, _verify_codegen(args))
445
+ if run_templates:
446
+ exit_code = max(exit_code, _verify_templates(args))
447
+ return exit_code
448
+
449
+
450
+ #: The root-doc filenames we look for / create, and the line we append so a
451
+ #: Claude Code agent always picks up the slim always-on context.
452
+ _ROOT_DOC_CANDIDATES = ("CLAUDE.md", "AGENTS.md")
453
+ _ROOT_DOC_IMPORT_LINE = "@.metaobjects/AGENTS.md"
454
+
455
+
456
+ def _detect_python_server(out_dir: Path) -> bool:
457
+ """Simple stack detection: a ``pyproject.toml`` in --out marks a Python project."""
458
+ return (out_dir / "pyproject.toml").is_file()
459
+
460
+
461
+ def _wire_root_doc(out_dir: Path) -> str | None:
462
+ """Append ``@.metaobjects/AGENTS.md`` to the root CLAUDE.md/AGENTS.md.
463
+
464
+ Idempotent — if the import line is already present in either doc, do nothing.
465
+ If neither doc exists, create ``CLAUDE.md`` with the import line. Returns the
466
+ doc filename that was created/updated, or ``None`` if it was already wired.
467
+ """
468
+ existing = [name for name in _ROOT_DOC_CANDIDATES if (out_dir / name).is_file()]
469
+ # Already wired in any existing doc → idempotent no-op.
470
+ for name in existing:
471
+ text = (out_dir / name).read_text(encoding="utf-8")
472
+ if _ROOT_DOC_IMPORT_LINE in text:
473
+ return None
474
+
475
+ if existing:
476
+ target = out_dir / existing[0]
477
+ text = target.read_text(encoding="utf-8")
478
+ sep = "" if text.endswith("\n") or text == "" else "\n"
479
+ target.write_text(f"{text}{sep}{_ROOT_DOC_IMPORT_LINE}\n", encoding="utf-8")
480
+ return existing[0]
481
+
482
+ target = out_dir / "CLAUDE.md"
483
+ target.write_text(f"{_ROOT_DOC_IMPORT_LINE}\n", encoding="utf-8")
484
+ return "CLAUDE.md"
485
+
486
+
487
+ def _cmd_agent_docs(args: argparse.Namespace) -> int:
488
+ """``agent-docs`` — scaffold the slim MetaObjects Claude Code agent context.
489
+
490
+ Resolve the stack from ``--server`` / ``--client`` (repeatable). With neither,
491
+ detect ``pyproject.toml`` → a python server. Assemble against the bundled
492
+ content tree, then write the files into ``--out`` (default cwd): each new or
493
+ manifest-unmodified file is written at its path; a hand-edited file's fresh
494
+ contents go to ``<path>.new``. Append the always-on import to a root
495
+ CLAUDE.md/AGENTS.md (idempotent), and persist the sidecar manifest.
496
+ """
497
+ out_dir = Path(args.out).resolve() if args.out else Path.cwd()
498
+
499
+ servers = list(args.server or [])
500
+ clients = list(args.client or [])
501
+ if not servers and not clients:
502
+ if _detect_python_server(out_dir):
503
+ servers = ["python"]
504
+ else:
505
+ print(
506
+ "error: no --server/--client given and no pyproject.toml found to "
507
+ "detect a stack. Pass at least one --server or --client.",
508
+ file=sys.stderr,
509
+ )
510
+ return 2
511
+
512
+ try:
513
+ content_root = resolve_agent_context_root()
514
+ except FileNotFoundError as e:
515
+ print(f"error: {e}", file=sys.stderr)
516
+ return 1
517
+
518
+ stack = make_stack(servers, clients)
519
+ assembled = assemble(content_root, stack)
520
+
521
+ # Load the prior manifest, if any, so hand-edits are preserved on re-run.
522
+ manifest_path = out_dir / AGENT_CONTEXT_MANIFEST_PATH
523
+ prior: Manifest | None = None
524
+ if manifest_path.is_file():
525
+ try:
526
+ prior = Manifest.from_json(
527
+ json.loads(manifest_path.read_text(encoding="utf-8"))
528
+ )
529
+ except (json.JSONDecodeError, ValueError, KeyError):
530
+ prior = None # corrupt sidecar → treat as a fresh scaffold
531
+
532
+ def _read_current(rel: str) -> str | None:
533
+ p = out_dir / rel
534
+ return p.read_bytes().decode("utf-8") if p.is_file() else None
535
+
536
+ decision = plan_scaffold(
537
+ stack,
538
+ assembled,
539
+ prior,
540
+ _read_current,
541
+ generated_by=installed_metaobjects_version(),
542
+ )
543
+
544
+ for w in decision.writes:
545
+ dest = out_dir / w.path
546
+ dest.parent.mkdir(parents=True, exist_ok=True)
547
+ dest.write_bytes(w.contents.encode("utf-8"))
548
+ print(f"wrote {w.path}")
549
+ for c in decision.conflicts:
550
+ dest = out_dir / c.new_path
551
+ dest.parent.mkdir(parents=True, exist_ok=True)
552
+ dest.write_bytes(c.contents.encode("utf-8"))
553
+ print(f"hand-edited; wrote fresh copy to {c.new_path} (kept your {c.path})")
554
+ for rel in decision.removed:
555
+ print(f"note: {rel} no longer applies to this stack (not deleted)")
556
+
557
+ # Persist the manifest.
558
+ assert decision.manifest is not None
559
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
560
+ manifest_path.write_text(
561
+ json.dumps(decision.manifest.to_json(), indent=2) + "\n", encoding="utf-8"
562
+ )
563
+
564
+ wired = _wire_root_doc(out_dir)
565
+ if wired is not None:
566
+ print(f"wired {_ROOT_DOC_IMPORT_LINE} into {wired}")
567
+
568
+ print(
569
+ f"metaobjects agent-docs: scaffolded {len(assembled)} file(s) for stack "
570
+ f"servers={list(stack.servers)} clients={list(stack.clients)} under {out_dir}"
571
+ )
572
+ return 0
573
+
574
+
575
+ def _build_parser() -> argparse.ArgumentParser:
576
+ parser = argparse.ArgumentParser(
577
+ prog="metaobjects",
578
+ description=(
579
+ "MetaObjects Python codegen CLI. Generate idiomatic Python from "
580
+ "metadata and verify it has not drifted. Schema migrations are "
581
+ "owned by the Node `meta` CLI (ADR-0015) — there is no `migrate` "
582
+ "subcommand here."
583
+ ),
584
+ )
585
+ sub = parser.add_subparsers(dest="command", required=True)
586
+
587
+ gen = sub.add_parser("gen", help="run codegen, writing files under --out")
588
+ # metadata_dir / --out are optional so `gen --list` works without them.
589
+ gen.add_argument(
590
+ "metadata_dir",
591
+ nargs="?",
592
+ default=None,
593
+ help="directory of metadata JSON/YAML files",
594
+ )
595
+ gen.add_argument("--out", default=None, help="output directory for generated code")
596
+ gen.add_argument(
597
+ "--generators",
598
+ default=None,
599
+ help=(
600
+ "comma-separated STABLE generator names to run (e.g. entity,routes). "
601
+ "Resolved via the registry; omit to run the default suite. "
602
+ "See `gen --list`."
603
+ ),
604
+ )
605
+ gen.add_argument(
606
+ "--list",
607
+ action="store_true",
608
+ help="list registered generators (stable name + description) and exit",
609
+ )
610
+ gen.add_argument(
611
+ "--package",
612
+ default=None,
613
+ help="(reserved) package hint; Python derives package from metadata",
614
+ )
615
+ gen.set_defaults(func=_cmd_gen)
616
+
617
+ verify = sub.add_parser(
618
+ "verify",
619
+ help=(
620
+ "drift gate — explicit subverbs --codegen / --templates / --db "
621
+ "(ADR-0021 D2); bare verify defaults to --codegen"
622
+ ),
623
+ )
624
+ verify.add_argument("metadata_dir", help="directory of metadata JSON/YAML files")
625
+ verify.add_argument(
626
+ "--codegen",
627
+ action="store_true",
628
+ help="codegen drift: regenerate to a temp dir + diff vs --out (default)",
629
+ )
630
+ verify.add_argument(
631
+ "--templates",
632
+ action="store_true",
633
+ help=(
634
+ "template drift: each template.* node's {{field}} ↔ payload-VO "
635
+ "field tree (render verify); requires --templates-root"
636
+ ),
637
+ )
638
+ verify.add_argument(
639
+ "--db",
640
+ default=None,
641
+ metavar="URL",
642
+ help="schema drift — NOT supported in the Python port (exit 2)",
643
+ )
644
+ verify.add_argument(
645
+ "--out",
646
+ default=None,
647
+ help="committed output directory to diff against (for --codegen)",
648
+ )
649
+ verify.add_argument(
650
+ "--templates-root",
651
+ dest="templates_root",
652
+ default=None,
653
+ help="on-disk template/prompt dir the --templates gate resolves refs against",
654
+ )
655
+ verify.set_defaults(func=_cmd_verify)
656
+
657
+ agent_docs = sub.add_parser(
658
+ "agent-docs",
659
+ help=(
660
+ "scaffold the slim MetaObjects Claude Code agent context "
661
+ "(.metaobjects/AGENTS.md + CLAUDE.md + the metaobjects-* skills)"
662
+ ),
663
+ )
664
+ agent_docs.add_argument(
665
+ "--server",
666
+ action="append",
667
+ default=None,
668
+ metavar="LANG",
669
+ help="server language (repeatable): typescript|java|kotlin|csharp|python",
670
+ )
671
+ agent_docs.add_argument(
672
+ "--client",
673
+ action="append",
674
+ default=None,
675
+ metavar="FRAMEWORK",
676
+ help="client framework (repeatable): react|tanstack|angular",
677
+ )
678
+ agent_docs.add_argument(
679
+ "--out",
680
+ default=None,
681
+ help="output project root (default: cwd)",
682
+ )
683
+ agent_docs.set_defaults(func=_cmd_agent_docs)
684
+
685
+ return parser
686
+
687
+
688
+ def main(argv: list[str] | None = None) -> int:
689
+ """Entry point. Returns the process exit code (does not call ``sys.exit``)."""
690
+ parser = _build_parser()
691
+ args = parser.parse_args(argv)
692
+ return int(args.func(args))
693
+
694
+
695
+ if __name__ == "__main__": # pragma: no cover
696
+ sys.exit(main())
File without changes