d365fo-agent-developer 0.6.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.
- d365fo_agent/__init__.py +2 -0
- d365fo_agent/aot_relations.py +147 -0
- d365fo_agent/build.py +285 -0
- d365fo_agent/cli.py +651 -0
- d365fo_agent/data/aot-type-profiles.json +1836 -0
- d365fo_agent/data/x++-methodology.md +152 -0
- d365fo_agent/data/x++-rules.json +48 -0
- d365fo_agent/entity_derive.py +176 -0
- d365fo_agent/generator.py +1393 -0
- d365fo_agent/graph_query.py +107 -0
- d365fo_agent/graphify_runner.py +304 -0
- d365fo_agent/index_store.py +465 -0
- d365fo_agent/indexer.py +292 -0
- d365fo_agent/knowledge.py +393 -0
- d365fo_agent/knowledge_fetch.py +70 -0
- d365fo_agent/linter.py +369 -0
- d365fo_agent/mcp_server.py +842 -0
- d365fo_agent/models.py +48 -0
- d365fo_agent/packageslocal_export.py +253 -0
- d365fo_agent/rules.py +42 -0
- d365fo_agent/security_wiring.py +198 -0
- d365fo_agent/specs.py +651 -0
- d365fo_agent/sql_model.py +342 -0
- d365fo_agent/type_profile.py +113 -0
- d365fo_agent/validate.py +180 -0
- d365fo_agent_developer-0.6.0.dist-info/METADATA +171 -0
- d365fo_agent_developer-0.6.0.dist-info/RECORD +31 -0
- d365fo_agent_developer-0.6.0.dist-info/WHEEL +5 -0
- d365fo_agent_developer-0.6.0.dist-info/entry_points.txt +3 -0
- d365fo_agent_developer-0.6.0.dist-info/licenses/LICENSE +21 -0
- d365fo_agent_developer-0.6.0.dist-info/top_level.txt +1 -0
d365fo_agent/cli.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from d365fo_agent.build import BuildRunner
|
|
10
|
+
from d365fo_agent.generator import build_generation_bundle, generate_from_spec_file
|
|
11
|
+
from d365fo_agent.graph_query import GraphIndex, discover_graph_path
|
|
12
|
+
from d365fo_agent.graphify_runner import run_graphify_staging
|
|
13
|
+
from d365fo_agent.indexer import (
|
|
14
|
+
build_catalog,
|
|
15
|
+
find_artifacts,
|
|
16
|
+
find_references,
|
|
17
|
+
find_reverse_references,
|
|
18
|
+
get_artifact_details,
|
|
19
|
+
merge_catalogs,
|
|
20
|
+
summarize_classifications,
|
|
21
|
+
)
|
|
22
|
+
from d365fo_agent.index_store import build_index_file
|
|
23
|
+
from d365fo_agent.linter import lint_artifact, load_lint_config
|
|
24
|
+
from d365fo_agent.packageslocal_export import export_packageslocal_to_graphify
|
|
25
|
+
from d365fo_agent.rules import load_rules
|
|
26
|
+
from d365fo_agent.specs import ArtifactSpec, build_artifact_plans, load_spec
|
|
27
|
+
from d365fo_agent.validate import FAMILY_ROOT, validate_file, validate_xml
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _force_utf8(*streams: object) -> None:
|
|
31
|
+
"""Force UTF-8 on the given text streams. On Windows stdout/stdin default to cp1252,
|
|
32
|
+
which raises UnicodeEncodeError on any character outside that codepage (✓, CJK, …) and
|
|
33
|
+
would corrupt the JSON-RPC stream. MCP mandates UTF-8, so we enforce it everywhere."""
|
|
34
|
+
for stream in streams:
|
|
35
|
+
try:
|
|
36
|
+
stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
|
37
|
+
except (AttributeError, ValueError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: list[str] | None = None) -> int:
|
|
42
|
+
_force_utf8(sys.stdout, sys.stderr, sys.stdin)
|
|
43
|
+
parser = _build_parser()
|
|
44
|
+
args = parser.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
if args.command == "inventory":
|
|
47
|
+
catalog = _build_catalog_from_args(args)
|
|
48
|
+
payload = {
|
|
49
|
+
"model_count": len(catalog.models),
|
|
50
|
+
"artifact_count": len(catalog.artifacts),
|
|
51
|
+
"classification_summary": summarize_classifications(catalog),
|
|
52
|
+
}
|
|
53
|
+
_dump_json(payload)
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
if args.command == "find-element":
|
|
57
|
+
catalog = _build_catalog_from_args(args)
|
|
58
|
+
matches = [artifact.to_dict() for artifact in find_artifacts(catalog, args.name, args.artifact_type)]
|
|
59
|
+
_dump_json({"matches": matches})
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
if args.command == "get-element-details":
|
|
63
|
+
catalog = _build_catalog_from_args(args)
|
|
64
|
+
_dump_json(get_artifact_details(catalog, args.name))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
if args.command == "find-references":
|
|
68
|
+
matches = find_references(args.repo_root, args.symbol)
|
|
69
|
+
_dump_json({"matches": matches})
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
if args.command == "find-reverse-references":
|
|
73
|
+
catalog = _build_catalog_from_args(args)
|
|
74
|
+
_dump_json({"matches": find_reverse_references(catalog, args.symbol)})
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
if args.command == "build-project":
|
|
78
|
+
runner = BuildRunner(msbuild_executable=args.msbuild)
|
|
79
|
+
result = runner.build_project(
|
|
80
|
+
args.project,
|
|
81
|
+
execute=args.execute,
|
|
82
|
+
output_path=args.output_path,
|
|
83
|
+
)
|
|
84
|
+
_dump_json(asdict(result))
|
|
85
|
+
return 0 if result.status in {"planned", "succeeded"} else 1
|
|
86
|
+
|
|
87
|
+
if args.command == "compile-model":
|
|
88
|
+
from d365fo_agent.build import XppCompiler
|
|
89
|
+
|
|
90
|
+
compiler = XppCompiler(args.packages_root, xppc_path=args.xppc)
|
|
91
|
+
out = Path(args.output_dir)
|
|
92
|
+
result = compiler.compile_model(
|
|
93
|
+
args.model,
|
|
94
|
+
output_path=out / "out",
|
|
95
|
+
log_path=out / "compile.log",
|
|
96
|
+
appchecker=args.appchecker,
|
|
97
|
+
xref_file=(out / "xref.txt") if args.xref else None,
|
|
98
|
+
)
|
|
99
|
+
_dump_json(result.to_dict())
|
|
100
|
+
if result.status == "succeeded":
|
|
101
|
+
return 0
|
|
102
|
+
return 2 if result.status == "unavailable" else 1
|
|
103
|
+
|
|
104
|
+
if args.command == "compile-generated":
|
|
105
|
+
from d365fo_agent.build import XppCompiler
|
|
106
|
+
|
|
107
|
+
package = args.package or args.model
|
|
108
|
+
files: list[Path] = []
|
|
109
|
+
if args.generated_dir:
|
|
110
|
+
files += [f for f in Path(args.generated_dir).rglob("*.xml") if not f.name.startswith("generation-")]
|
|
111
|
+
files += [Path(p) for p in (args.file or [])]
|
|
112
|
+
overlays = [
|
|
113
|
+
(f"{package}/{args.model}/{f.parent.name}/{f.name}", f.read_text(encoding="utf-8", errors="ignore"))
|
|
114
|
+
for f in files
|
|
115
|
+
]
|
|
116
|
+
if not overlays:
|
|
117
|
+
_dump_json({"status": "failed", "message": "No artifact files found (use --generated-dir and/or --file)."})
|
|
118
|
+
return 1
|
|
119
|
+
compiler = XppCompiler(args.packages_root)
|
|
120
|
+
out = Path(args.output_dir)
|
|
121
|
+
result = compiler.compile_overlay(
|
|
122
|
+
args.model, overlays, output_path=out / "out", log_path=out / "compile.log", appchecker=args.appchecker
|
|
123
|
+
)
|
|
124
|
+
_dump_json({**result.to_dict(), "overlaid": [rel for rel, _ in overlays]})
|
|
125
|
+
if result.status == "succeeded":
|
|
126
|
+
return 0
|
|
127
|
+
return 2 if result.status == "unavailable" else 1
|
|
128
|
+
|
|
129
|
+
if args.command == "analyze-spec":
|
|
130
|
+
spec = load_spec(args.spec)
|
|
131
|
+
catalog = _build_catalog_from_args(args)
|
|
132
|
+
plans = build_artifact_plans(spec)
|
|
133
|
+
spec_blocks = spec.artifact_specs if spec.artifact_specs else [ArtifactSpec(spec.title, spec.metadata, spec.sections)]
|
|
134
|
+
graph_index = None
|
|
135
|
+
resolved_graph_path = Path(args.graph) if getattr(args, "graph", None) else discover_graph_path(args.repo_root)
|
|
136
|
+
if resolved_graph_path and resolved_graph_path.exists():
|
|
137
|
+
graph_index = GraphIndex(resolved_graph_path)
|
|
138
|
+
bundles = [
|
|
139
|
+
build_generation_bundle(
|
|
140
|
+
spec_block,
|
|
141
|
+
plan,
|
|
142
|
+
catalog,
|
|
143
|
+
args.repo_root,
|
|
144
|
+
example_limit=args.example_limit,
|
|
145
|
+
graph_index=graph_index,
|
|
146
|
+
graph_query=(plan.artifact_name or plan.target_object or plan.service_class),
|
|
147
|
+
)
|
|
148
|
+
for spec_block, plan in zip(spec_blocks, plans, strict=True)
|
|
149
|
+
]
|
|
150
|
+
payload: dict[str, object] = {"spec": spec.to_dict(), "artifacts": bundles}
|
|
151
|
+
if len(plans) == 1:
|
|
152
|
+
payload["artifact_plan"] = plans[0].to_dict()
|
|
153
|
+
payload["examples"] = bundles[0]["examples"]
|
|
154
|
+
else:
|
|
155
|
+
payload["artifact_plans"] = [plan.to_dict() for plan in plans]
|
|
156
|
+
_dump_json(payload)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
if args.command == "generate-from-spec":
|
|
160
|
+
result = generate_from_spec_file(
|
|
161
|
+
args.spec,
|
|
162
|
+
args.repo_root,
|
|
163
|
+
args.rules,
|
|
164
|
+
args.output_dir,
|
|
165
|
+
example_limit=args.example_limit,
|
|
166
|
+
graph_path=getattr(args, "graph", None),
|
|
167
|
+
db_path=getattr(args, "db", None),
|
|
168
|
+
)
|
|
169
|
+
_dump_json(result)
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
if args.command == "export-packageslocal-graphify":
|
|
173
|
+
result = export_packageslocal_to_graphify(args.packages_root, args.output_dir)
|
|
174
|
+
_dump_json(result)
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
if args.command == "run-graphify-staging":
|
|
178
|
+
result = run_graphify_staging(args.staging_dir, args.output_dir, include_html=not args.no_html)
|
|
179
|
+
_dump_json(result)
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
if args.command == "build-index":
|
|
183
|
+
if not args.repo_root and not args.packages_root:
|
|
184
|
+
parser.error("build-index needs --repo-root (custom) and/or --packages-root (standard).")
|
|
185
|
+
# repo_root/rules are optional: omit them to build a STANDARD-only index (the shippable
|
|
186
|
+
# knowledge base) from a PackagesLocalDirectory alone. --repo-root is repeatable: all
|
|
187
|
+
# corpora are merged into ONE catalog because rebuilding "custom" replaces every custom row.
|
|
188
|
+
catalog = None
|
|
189
|
+
if args.repo_root:
|
|
190
|
+
rules = load_rules(args.rules)
|
|
191
|
+
catalog = merge_catalogs([build_catalog(Path(root), rules) for root in args.repo_root])
|
|
192
|
+
|
|
193
|
+
def _progress(package: str, count: int) -> None:
|
|
194
|
+
sys.stderr.write(f"[build-index] {package}: +{count}\n")
|
|
195
|
+
sys.stderr.flush()
|
|
196
|
+
|
|
197
|
+
stats = build_index_file(
|
|
198
|
+
args.db,
|
|
199
|
+
catalog,
|
|
200
|
+
packages_root=args.packages_root,
|
|
201
|
+
rebuild=args.rebuild,
|
|
202
|
+
progress=_progress if args.packages_root else None,
|
|
203
|
+
exclude_packages=args.exclude_package,
|
|
204
|
+
)
|
|
205
|
+
_dump_json(stats)
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
if args.command == "extract-aot-relations":
|
|
209
|
+
from d365fo_agent.aot_relations import extract_aot_relations
|
|
210
|
+
|
|
211
|
+
def _rel_progress(root: str, count: int) -> None:
|
|
212
|
+
sys.stderr.write(f"[extract-aot-relations] {root}: {count} relations\n")
|
|
213
|
+
sys.stderr.flush()
|
|
214
|
+
|
|
215
|
+
stats = extract_aot_relations(args.root, args.db, progress=_rel_progress)
|
|
216
|
+
_dump_json(stats)
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
if args.command == "serve-mcp":
|
|
220
|
+
from d365fo_agent.mcp_server import build_server_from_config, default_knowledge_db
|
|
221
|
+
|
|
222
|
+
db = args.db or (str(default_knowledge_db()) if default_knowledge_db().exists() else None)
|
|
223
|
+
if not db and not args.repo_root:
|
|
224
|
+
parser.error(
|
|
225
|
+
"No knowledge index found. Run 'd365fo-agent fetch-knowledge' to download the standard "
|
|
226
|
+
"D365 knowledge base, or pass --db <index.db> / --repo-root <your D365 repo>."
|
|
227
|
+
)
|
|
228
|
+
server = build_server_from_config(
|
|
229
|
+
args.repo_root,
|
|
230
|
+
args.rules,
|
|
231
|
+
db_path=db,
|
|
232
|
+
packages_root=args.packages_root,
|
|
233
|
+
methodology_path=args.methodology,
|
|
234
|
+
lint_rules_path=args.lint_rules,
|
|
235
|
+
extra_roots=args.extra_root,
|
|
236
|
+
sql_model_path=args.sql_model,
|
|
237
|
+
)
|
|
238
|
+
server.serve_stdio()
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
if args.command == "fetch-knowledge":
|
|
242
|
+
from d365fo_agent.knowledge_fetch import fetch_knowledge
|
|
243
|
+
|
|
244
|
+
result = fetch_knowledge(args.url, args.dest, force=args.force)
|
|
245
|
+
_dump_json(result)
|
|
246
|
+
return 0 if result.get("ok") else 1
|
|
247
|
+
|
|
248
|
+
if args.command == "validate-xml":
|
|
249
|
+
profiles = None
|
|
250
|
+
if args.profiles:
|
|
251
|
+
from d365fo_agent.type_profile import load_type_profiles
|
|
252
|
+
|
|
253
|
+
profiles = load_type_profiles(args.profiles)
|
|
254
|
+
if args.file:
|
|
255
|
+
report = validate_file(args.file, args.family, type_profiles=profiles)
|
|
256
|
+
else:
|
|
257
|
+
report = validate_xml(sys.stdin.read(), args.family, type_profiles=profiles)
|
|
258
|
+
_dump_json(report)
|
|
259
|
+
return 0 if report["valid"] else 1
|
|
260
|
+
|
|
261
|
+
if args.command == "build-type-profiles":
|
|
262
|
+
from d365fo_agent.index_store import D365Index
|
|
263
|
+
from d365fo_agent.type_profile import build_type_profiles, default_profiles_path, save_type_profiles
|
|
264
|
+
|
|
265
|
+
roots = [Path(args.repo_root)]
|
|
266
|
+
roots.append(Path(args.packages_root) if args.packages_root else Path(args.repo_root) / "PackagesLocalDirectory")
|
|
267
|
+
index = D365Index(args.db)
|
|
268
|
+
try:
|
|
269
|
+
def _profile_progress(root: str, n: int) -> None:
|
|
270
|
+
sys.stderr.write(f"[type-profiles] {root}: {n}\n")
|
|
271
|
+
sys.stderr.flush()
|
|
272
|
+
|
|
273
|
+
profiles = build_type_profiles(index, roots, sample_per_type=args.sample_per_type, progress=_profile_progress)
|
|
274
|
+
finally:
|
|
275
|
+
index.close()
|
|
276
|
+
out = args.out or str(default_profiles_path(args.db))
|
|
277
|
+
save_type_profiles(profiles, out)
|
|
278
|
+
_dump_json({"types_profiled": len(profiles), "output": str(out).replace("\\", "/"),
|
|
279
|
+
"sample_per_type": args.sample_per_type})
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
if args.command == "lint":
|
|
283
|
+
from d365fo_agent.index_store import D365Index
|
|
284
|
+
|
|
285
|
+
cfg_path = args.rules_config or "config/x++-rules.json"
|
|
286
|
+
config = load_lint_config(cfg_path) if Path(cfg_path).exists() else load_lint_config()
|
|
287
|
+
xml_text = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
|
|
288
|
+
index = D365Index(args.db) if args.db else None
|
|
289
|
+
roots = None
|
|
290
|
+
if args.repo_root:
|
|
291
|
+
roots = [Path(args.repo_root)]
|
|
292
|
+
roots.append(Path(args.packages_root) if args.packages_root else Path(args.repo_root) / "PackagesLocalDirectory")
|
|
293
|
+
try:
|
|
294
|
+
report = lint_artifact(xml_text, args.family, index=index, config=config, model=args.model, roots=roots)
|
|
295
|
+
finally:
|
|
296
|
+
if index is not None:
|
|
297
|
+
index.close()
|
|
298
|
+
_dump_json(report)
|
|
299
|
+
return 0 if report["error_count"] == 0 else 1
|
|
300
|
+
|
|
301
|
+
if args.command == "derive-entity":
|
|
302
|
+
from d365fo_agent import entity_derive, knowledge
|
|
303
|
+
from d365fo_agent.index_store import D365Index
|
|
304
|
+
|
|
305
|
+
index = D365Index(args.db)
|
|
306
|
+
try:
|
|
307
|
+
matches = index.lookup_exact(args.source, "AxDataEntityView")
|
|
308
|
+
if not matches:
|
|
309
|
+
index.close()
|
|
310
|
+
_dump_json({"found": False, "source": args.source, "error": "source data entity not found in index"})
|
|
311
|
+
return 1
|
|
312
|
+
roots = [Path(args.repo_root), Path(args.repo_root) / "PackagesLocalDirectory"]
|
|
313
|
+
if args.packages_root:
|
|
314
|
+
roots.append(Path(args.packages_root))
|
|
315
|
+
path = knowledge._resolve_file(matches[0].get("relative_path"), roots)
|
|
316
|
+
if path is None:
|
|
317
|
+
_dump_json({"found": True, "source_available": False, "source": args.source})
|
|
318
|
+
return 1
|
|
319
|
+
source_xml = path.read_text(encoding="utf-8", errors="ignore")
|
|
320
|
+
grants = [g.strip() for g in args.grants.split(",")] if args.grants else None
|
|
321
|
+
entity = entity_derive.derive_public_entity(
|
|
322
|
+
source_xml, args.new_name,
|
|
323
|
+
public_entity_name=args.public_entity_name,
|
|
324
|
+
public_collection_name=args.public_collection_name,
|
|
325
|
+
label=args.label,
|
|
326
|
+
data_management=(True if args.data_management else None),
|
|
327
|
+
staging_table=args.staging_table,
|
|
328
|
+
)
|
|
329
|
+
privilege = entity_derive.build_entity_privilege(
|
|
330
|
+
args.new_name, label=args.privilege_label, grants=grants,
|
|
331
|
+
integration_mode=args.integration_mode or "OData",
|
|
332
|
+
)
|
|
333
|
+
finally:
|
|
334
|
+
index.close()
|
|
335
|
+
|
|
336
|
+
out_dir = Path(args.output_dir)
|
|
337
|
+
(out_dir / "AxDataEntityView").mkdir(parents=True, exist_ok=True)
|
|
338
|
+
(out_dir / "AxSecurityPrivilege").mkdir(parents=True, exist_ok=True)
|
|
339
|
+
entity_file = out_dir / "AxDataEntityView" / f"{entity['name']}.xml"
|
|
340
|
+
priv_file = out_dir / "AxSecurityPrivilege" / f"{privilege['name']}.xml"
|
|
341
|
+
entity_file.write_text(entity["xml"], encoding="utf-8")
|
|
342
|
+
priv_file.write_text(privilege["xml"], encoding="utf-8")
|
|
343
|
+
_dump_json({
|
|
344
|
+
"source": args.source,
|
|
345
|
+
"entity": {k: v for k, v in entity.items() if k != "xml"},
|
|
346
|
+
"entity_file": str(entity_file).replace("\\", "/"),
|
|
347
|
+
"privilege": {k: v for k, v in privilege.items() if k != "xml"},
|
|
348
|
+
"privilege_file": str(priv_file).replace("\\", "/"),
|
|
349
|
+
"review_checklist": entity_derive.REVIEW_CHECKLIST,
|
|
350
|
+
})
|
|
351
|
+
return 0
|
|
352
|
+
|
|
353
|
+
if args.command == "wire-security":
|
|
354
|
+
from d365fo_agent import security_wiring
|
|
355
|
+
|
|
356
|
+
extend_duty = not args.new_duty
|
|
357
|
+
extend_role = not args.new_role
|
|
358
|
+
result = security_wiring.wire_security(
|
|
359
|
+
args.privilege, duty=args.duty, role=args.role,
|
|
360
|
+
extend_duty=extend_duty, extend_role=extend_role, suffix=args.suffix,
|
|
361
|
+
duty_label=args.duty_label, role_label=args.role_label, role_description=args.role_description,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Verify extension targets against the index (warn, don't block — referenced by name only).
|
|
365
|
+
target_checks: list[dict[str, object]] = []
|
|
366
|
+
warnings: list[str] = []
|
|
367
|
+
if args.db:
|
|
368
|
+
from d365fo_agent.index_store import D365Index
|
|
369
|
+
|
|
370
|
+
index = D365Index(args.db)
|
|
371
|
+
try:
|
|
372
|
+
for kind, name, do_extend, atype in (
|
|
373
|
+
("duty", args.duty, extend_duty, "AxSecurityDuty"),
|
|
374
|
+
("role", args.role, extend_role, "AxSecurityRole"),
|
|
375
|
+
):
|
|
376
|
+
if name and do_extend:
|
|
377
|
+
in_index = index.exists(name, atype)
|
|
378
|
+
target_checks.append({"kind": kind, "name": name, "in_index": in_index})
|
|
379
|
+
if not in_index:
|
|
380
|
+
warnings.append(
|
|
381
|
+
f"Extension target {kind} '{name}' is not indexed as {atype}. Confirm the exact "
|
|
382
|
+
f"standard {kind} name, or use --new-{kind} to create a custom object instead."
|
|
383
|
+
)
|
|
384
|
+
finally:
|
|
385
|
+
index.close()
|
|
386
|
+
|
|
387
|
+
out_dir = Path(args.output_dir)
|
|
388
|
+
written = []
|
|
389
|
+
for art in result["artifacts"]:
|
|
390
|
+
folder = FAMILY_ROOT.get(str(art["family"]), "AxUnknown")
|
|
391
|
+
(out_dir / folder).mkdir(parents=True, exist_ok=True)
|
|
392
|
+
fpath = out_dir / folder / f"{art['name']}.xml"
|
|
393
|
+
fpath.write_text(str(art["xml"]), encoding="utf-8")
|
|
394
|
+
written.append({"family": art["family"], "name": art["name"],
|
|
395
|
+
"file": str(fpath).replace("\\", "/"),
|
|
396
|
+
"validate": validate_xml(str(art["xml"]), str(art["family"]))})
|
|
397
|
+
_dump_json({
|
|
398
|
+
"wired": True, "privilege": result["privilege"], "chain": result["chain"],
|
|
399
|
+
"artifacts": written, "target_checks": target_checks, "warnings": warnings,
|
|
400
|
+
"review_checklist": result["review_checklist"],
|
|
401
|
+
})
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
if args.command == "scaffold":
|
|
405
|
+
from d365fo_agent import knowledge
|
|
406
|
+
from d365fo_agent.index_store import D365Index
|
|
407
|
+
|
|
408
|
+
properties = {}
|
|
409
|
+
for item in args.set or []:
|
|
410
|
+
if "=" in item:
|
|
411
|
+
key, value = item.split("=", 1)
|
|
412
|
+
properties[key.strip()] = value.strip()
|
|
413
|
+
index = D365Index(args.db)
|
|
414
|
+
try:
|
|
415
|
+
roots = [Path(args.repo_root)]
|
|
416
|
+
roots.append(Path(args.packages_root) if args.packages_root else Path(args.repo_root) / "PackagesLocalDirectory")
|
|
417
|
+
result = knowledge.scaffold_object(
|
|
418
|
+
index, args.artifact_type, roots, new_name=args.new_name, query=args.query,
|
|
419
|
+
properties=properties or None,
|
|
420
|
+
)
|
|
421
|
+
finally:
|
|
422
|
+
index.close()
|
|
423
|
+
if args.output and result.get("xml"):
|
|
424
|
+
out = Path(args.output)
|
|
425
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
426
|
+
out.write_text(str(result["xml"]), encoding="utf-8")
|
|
427
|
+
result["written_to"] = str(out).replace("\\", "/")
|
|
428
|
+
_dump_json(result)
|
|
429
|
+
return 0 if result.get("found") else 1
|
|
430
|
+
|
|
431
|
+
parser.error(f"Unsupported command: {args.command}")
|
|
432
|
+
return 2
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
436
|
+
parser = argparse.ArgumentParser(prog="d365fo-agent")
|
|
437
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
438
|
+
|
|
439
|
+
inventory = subparsers.add_parser("inventory")
|
|
440
|
+
_add_catalog_args(inventory)
|
|
441
|
+
|
|
442
|
+
find_element = subparsers.add_parser("find-element")
|
|
443
|
+
_add_catalog_args(find_element)
|
|
444
|
+
find_element.add_argument("--name", required=True)
|
|
445
|
+
find_element.add_argument("--artifact-type")
|
|
446
|
+
|
|
447
|
+
element_details = subparsers.add_parser("get-element-details")
|
|
448
|
+
_add_catalog_args(element_details)
|
|
449
|
+
element_details.add_argument("--name", required=True)
|
|
450
|
+
|
|
451
|
+
references = subparsers.add_parser("find-references")
|
|
452
|
+
references.add_argument("--repo-root", required=True)
|
|
453
|
+
references.add_argument("--symbol", required=True)
|
|
454
|
+
|
|
455
|
+
reverse_references = subparsers.add_parser("find-reverse-references")
|
|
456
|
+
_add_catalog_args(reverse_references)
|
|
457
|
+
reverse_references.add_argument("--symbol", required=True)
|
|
458
|
+
|
|
459
|
+
build_project = subparsers.add_parser("build-project")
|
|
460
|
+
build_project.add_argument("--project", required=True)
|
|
461
|
+
build_project.add_argument("--output-path")
|
|
462
|
+
build_project.add_argument("--msbuild", default="msbuild.exe")
|
|
463
|
+
build_project.add_argument("--execute", action="store_true")
|
|
464
|
+
|
|
465
|
+
compile_model_cmd = subparsers.add_parser(
|
|
466
|
+
"compile-model", help="Compile a model with the real X++ compiler (xppc.exe); structured diagnostics."
|
|
467
|
+
)
|
|
468
|
+
compile_model_cmd.add_argument("--packages-root", required=True, help="PackagesLocalDirectory (metadata + bin/xppc.exe).")
|
|
469
|
+
compile_model_cmd.add_argument("--model", required=True, help="Model/module name to compile (e.g. BABAccountsPayable).")
|
|
470
|
+
compile_model_cmd.add_argument("--output-dir", required=True, help="Where the assembly + compile.log are written.")
|
|
471
|
+
compile_model_cmd.add_argument("--appchecker", action="store_true", help="Also run the Best-Practice (Appchecker) rules.")
|
|
472
|
+
compile_model_cmd.add_argument("--xref", action="store_true", help="Also update the cross-reference data.")
|
|
473
|
+
compile_model_cmd.add_argument("--xppc", help="Override path to xppc.exe (default: <packages-root>/bin/xppc.exe).")
|
|
474
|
+
|
|
475
|
+
compile_generated_cmd = subparsers.add_parser(
|
|
476
|
+
"compile-generated",
|
|
477
|
+
help="Overlay generated artifact(s) into their model in the PLD, compile, then restore — proves generated X++ compiles.",
|
|
478
|
+
)
|
|
479
|
+
compile_generated_cmd.add_argument("--packages-root", required=True, help="PackagesLocalDirectory (metadata + bin/xppc.exe).")
|
|
480
|
+
compile_generated_cmd.add_argument("--model", required=True, help="Target model/module the artifacts belong to.")
|
|
481
|
+
compile_generated_cmd.add_argument("--package", help="Package folder (defaults to the model name).")
|
|
482
|
+
compile_generated_cmd.add_argument("--generated-dir", help="A generate-from-spec output dir; all its *.xml are overlaid.")
|
|
483
|
+
compile_generated_cmd.add_argument("--file", action="append", help="An explicit artifact XML file (repeatable).")
|
|
484
|
+
compile_generated_cmd.add_argument("--output-dir", required=True, help="Where the compile log/assembly are written.")
|
|
485
|
+
compile_generated_cmd.add_argument("--appchecker", action="store_true", help="Also run the Best-Practice (Appchecker) rules.")
|
|
486
|
+
|
|
487
|
+
analyze_spec = subparsers.add_parser("analyze-spec")
|
|
488
|
+
_add_catalog_args(analyze_spec)
|
|
489
|
+
analyze_spec.add_argument("--spec", required=True)
|
|
490
|
+
analyze_spec.add_argument("--example-limit", type=int, default=3)
|
|
491
|
+
analyze_spec.add_argument("--graph")
|
|
492
|
+
|
|
493
|
+
generate_from_spec = subparsers.add_parser("generate-from-spec")
|
|
494
|
+
_add_catalog_args(generate_from_spec)
|
|
495
|
+
generate_from_spec.add_argument("--spec", required=True)
|
|
496
|
+
generate_from_spec.add_argument("--output-dir", required=True)
|
|
497
|
+
generate_from_spec.add_argument("--example-limit", type=int, default=3)
|
|
498
|
+
generate_from_spec.add_argument("--graph")
|
|
499
|
+
generate_from_spec.add_argument("--db", help="SQLite index — resolves table-extension field types from the real EDT base type.")
|
|
500
|
+
|
|
501
|
+
export_packageslocal = subparsers.add_parser("export-packageslocal-graphify")
|
|
502
|
+
export_packageslocal.add_argument("--packages-root", required=True)
|
|
503
|
+
export_packageslocal.add_argument("--output-dir", required=True)
|
|
504
|
+
|
|
505
|
+
run_graphify = subparsers.add_parser("run-graphify-staging")
|
|
506
|
+
run_graphify.add_argument("--staging-dir", required=True)
|
|
507
|
+
run_graphify.add_argument("--output-dir", required=True)
|
|
508
|
+
run_graphify.add_argument("--no-html", action="store_true")
|
|
509
|
+
|
|
510
|
+
build_index = subparsers.add_parser("build-index", help="Build the SQLite FTS5 index (standard and/or custom).")
|
|
511
|
+
build_index.add_argument(
|
|
512
|
+
"--repo-root", action="append", default=None,
|
|
513
|
+
help="Custom D365 source repo (repeatable — all corpora are merged; omit to build a "
|
|
514
|
+
"STANDARD-only knowledge index).",
|
|
515
|
+
)
|
|
516
|
+
build_index.add_argument("--rules", help="Classification rules JSON (required with --repo-root).")
|
|
517
|
+
build_index.add_argument("--db", required=True, help="Output SQLite database path, e.g. .omx/index/d365fo.db")
|
|
518
|
+
build_index.add_argument("--packages-root", help="PackagesLocalDirectory to index the standard D365 corpus.")
|
|
519
|
+
build_index.add_argument("--rebuild", action="store_true", help="Delete and rebuild the DB from scratch.")
|
|
520
|
+
build_index.add_argument(
|
|
521
|
+
"--exclude-package",
|
|
522
|
+
action="append",
|
|
523
|
+
default=None,
|
|
524
|
+
metavar="PATTERN",
|
|
525
|
+
help="fnmatch pattern of PLD packages to skip (repeatable), e.g. --exclude-package 'BAB*' "
|
|
526
|
+
"to keep custom/ISV code out of a publishable standard index.",
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
extract_aot = subparsers.add_parser(
|
|
530
|
+
"extract-aot-relations",
|
|
531
|
+
help="Parse <Relations> from every AxTable/AxTableExtension (the AOT foreign keys) "
|
|
532
|
+
"into the SQL data model database.",
|
|
533
|
+
)
|
|
534
|
+
extract_aot.add_argument("--db", required=True, help="SQL model SQLite path, e.g. .omx/index/sqlmodel-raw.db")
|
|
535
|
+
extract_aot.add_argument(
|
|
536
|
+
"--root", action="append", required=True,
|
|
537
|
+
help="Corpus root to walk (repeatable): a PackagesLocalDirectory and/or a source tree.",
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
serve_mcp = subparsers.add_parser("serve-mcp", help="Run the stdio MCP server exposing D365 knowledge tools.")
|
|
541
|
+
serve_mcp.add_argument("--repo-root", help="Optional custom D365 repo (omit to serve from the knowledge index alone).")
|
|
542
|
+
serve_mcp.add_argument("--rules", help="Classification rules JSON (only with --repo-root).")
|
|
543
|
+
serve_mcp.add_argument("--db", help="SQLite index path (defaults to the fetched knowledge cache ~/.d365fo-agent/d365fo.db).")
|
|
544
|
+
serve_mcp.add_argument("--packages-root", help="PackagesLocalDirectory — enables source-reading tools (signatures, examples).")
|
|
545
|
+
serve_mcp.add_argument(
|
|
546
|
+
"--extra-root", action="append", default=None,
|
|
547
|
+
help="Additional source corpus root indexed into the same DB (repeatable).",
|
|
548
|
+
)
|
|
549
|
+
serve_mcp.add_argument("--lint-rules", help="X++ lint rules JSON (defaults to the bundled rules).")
|
|
550
|
+
serve_mcp.add_argument(
|
|
551
|
+
"--sql-model",
|
|
552
|
+
help="SQLite SQL data model extracted from a deployed D365 database (enables get_sql_model; "
|
|
553
|
+
"defaults to a sqlmodel-raw.db next to the knowledge index).",
|
|
554
|
+
)
|
|
555
|
+
serve_mcp.add_argument("--methodology")
|
|
556
|
+
|
|
557
|
+
fetch_knowledge_cmd = subparsers.add_parser(
|
|
558
|
+
"fetch-knowledge", help="Download the prebuilt standard-D365 knowledge index to the local cache."
|
|
559
|
+
)
|
|
560
|
+
fetch_knowledge_cmd.add_argument("--url", help="Release-asset URL (.db or .db.gz). Defaults to the built-in published URL.")
|
|
561
|
+
fetch_knowledge_cmd.add_argument("--dest", help="Destination path (defaults to ~/.d365fo-agent/d365fo.db).")
|
|
562
|
+
fetch_knowledge_cmd.add_argument("--force", action="store_true", help="Re-download even if already present.")
|
|
563
|
+
|
|
564
|
+
validate_xml_cmd = subparsers.add_parser("validate-xml", help="Validate AOT XML (file or stdin) offline.")
|
|
565
|
+
validate_xml_cmd.add_argument("--file", help="Path to the XML file. Omit to read XML from stdin.")
|
|
566
|
+
validate_xml_cmd.add_argument("--family", help="Expected artifact family (e.g. service, data-entity).")
|
|
567
|
+
validate_xml_cmd.add_argument("--profiles", help="Path to aot-type-profiles.json for universal (learned) structural rules.")
|
|
568
|
+
|
|
569
|
+
type_profiles_cmd = subparsers.add_parser(
|
|
570
|
+
"build-type-profiles", help="Learn per-type structural rules from the corpus (for universal validate_xml)."
|
|
571
|
+
)
|
|
572
|
+
type_profiles_cmd.add_argument("--repo-root", required=True)
|
|
573
|
+
type_profiles_cmd.add_argument("--db", required=True, help="SQLite index (source of type names + example paths).")
|
|
574
|
+
type_profiles_cmd.add_argument("--packages-root")
|
|
575
|
+
type_profiles_cmd.add_argument("--sample-per-type", type=int, default=200)
|
|
576
|
+
type_profiles_cmd.add_argument("--out", help="Output JSON (default: <db dir>/aot-type-profiles.json).")
|
|
577
|
+
|
|
578
|
+
lint_cmd = subparsers.add_parser("lint", help="Lint AOT XML against the X++ coding rules.")
|
|
579
|
+
lint_cmd.add_argument("--file", help="Path to the XML file. Omit to read XML from stdin.")
|
|
580
|
+
lint_cmd.add_argument("--family", help="Artifact family hint (optional).")
|
|
581
|
+
lint_cmd.add_argument("--model", help="Owning model name (optional, refines naming/extension rules).")
|
|
582
|
+
lint_cmd.add_argument("--db", help="SQLite index for index-backed rules (target existence, EDT types).")
|
|
583
|
+
lint_cmd.add_argument("--rules-config", help="Path to x++-rules.json (defaults to config/x++-rules.json).")
|
|
584
|
+
lint_cmd.add_argument("--repo-root", help="Repo root — enables reading EDT i:type to type STANDARD EDT fields.")
|
|
585
|
+
lint_cmd.add_argument("--packages-root", help="PackagesLocalDirectory (defaults to <repo-root>/PackagesLocalDirectory).")
|
|
586
|
+
|
|
587
|
+
derive_entity_cmd = subparsers.add_parser(
|
|
588
|
+
"derive-entity", help="Duplicate a standard data entity into a new public OData entity + privilege."
|
|
589
|
+
)
|
|
590
|
+
derive_entity_cmd.add_argument("--repo-root", required=True)
|
|
591
|
+
derive_entity_cmd.add_argument("--db", required=True, help="SQLite index used to locate the source entity.")
|
|
592
|
+
derive_entity_cmd.add_argument("--source", required=True, help="Source data entity name (e.g. CustCustomerV3Entity).")
|
|
593
|
+
derive_entity_cmd.add_argument("--new-name", required=True, help="New custom entity name (prefixed).")
|
|
594
|
+
derive_entity_cmd.add_argument("--output-dir", required=True)
|
|
595
|
+
derive_entity_cmd.add_argument("--packages-root")
|
|
596
|
+
derive_entity_cmd.add_argument("--public-entity-name")
|
|
597
|
+
derive_entity_cmd.add_argument("--public-collection-name")
|
|
598
|
+
derive_entity_cmd.add_argument("--label")
|
|
599
|
+
derive_entity_cmd.add_argument("--data-management", action="store_true")
|
|
600
|
+
derive_entity_cmd.add_argument("--staging-table")
|
|
601
|
+
derive_entity_cmd.add_argument("--integration-mode", default="OData")
|
|
602
|
+
derive_entity_cmd.add_argument("--grants", help="Comma-separated grants, e.g. Read,Create,Update,Delete.")
|
|
603
|
+
derive_entity_cmd.add_argument("--privilege-label")
|
|
604
|
+
|
|
605
|
+
wire_security_cmd = subparsers.add_parser(
|
|
606
|
+
"wire-security", help="Wire a privilege into a duty/role (extension-first) so it actually grants access."
|
|
607
|
+
)
|
|
608
|
+
wire_security_cmd.add_argument("--privilege", required=True, help="Privilege name to grant (e.g. from derive-entity).")
|
|
609
|
+
wire_security_cmd.add_argument("--duty", help="Duty to place the privilege in (standard duty to extend, or new custom duty name).")
|
|
610
|
+
wire_security_cmd.add_argument("--role", help="Role to attach the duty/privilege to (standard role to extend, or new custom role name).")
|
|
611
|
+
wire_security_cmd.add_argument("--new-duty", action="store_true", help="Create a new custom AxSecurityDuty instead of extending a standard one.")
|
|
612
|
+
wire_security_cmd.add_argument("--new-role", action="store_true", help="Create a new custom AxSecurityRole instead of extending a standard one.")
|
|
613
|
+
wire_security_cmd.add_argument("--suffix", help="Extension-name suffix (your model name); defaults to the privilege name.")
|
|
614
|
+
wire_security_cmd.add_argument("--duty-label")
|
|
615
|
+
wire_security_cmd.add_argument("--role-label")
|
|
616
|
+
wire_security_cmd.add_argument("--role-description")
|
|
617
|
+
wire_security_cmd.add_argument("--output-dir", required=True)
|
|
618
|
+
wire_security_cmd.add_argument("--db", help="SQLite index to verify standard duty/role targets exist (recommended when extending).")
|
|
619
|
+
|
|
620
|
+
scaffold_cmd = subparsers.add_parser(
|
|
621
|
+
"scaffold", help="Clone a real corpus example of ANY AOT type as a starting skeleton for a new object."
|
|
622
|
+
)
|
|
623
|
+
scaffold_cmd.add_argument("--repo-root", required=True)
|
|
624
|
+
scaffold_cmd.add_argument("--db", required=True, help="SQLite index used to find an example.")
|
|
625
|
+
scaffold_cmd.add_argument("--artifact-type", required=True, help="AOT type, e.g. AxView, AxWorkflowApproval, AxEnumExtension.")
|
|
626
|
+
scaffold_cmd.add_argument("--new-name", help="Rename the scaffold's root <Name> to this.")
|
|
627
|
+
scaffold_cmd.add_argument("--query", help="Bias example selection toward a relevant one.")
|
|
628
|
+
scaffold_cmd.add_argument("--set", action="append", metavar="Element=Value",
|
|
629
|
+
help="Set a top-level element on the skeleton (repeatable), e.g. --set Label=@My:Foo.")
|
|
630
|
+
scaffold_cmd.add_argument("--packages-root")
|
|
631
|
+
scaffold_cmd.add_argument("--output", help="Write the scaffold XML to this path.")
|
|
632
|
+
|
|
633
|
+
return parser
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _add_catalog_args(parser: argparse.ArgumentParser) -> None:
|
|
637
|
+
parser.add_argument("--repo-root", required=True)
|
|
638
|
+
parser.add_argument("--rules", required=True)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _build_catalog_from_args(args: argparse.Namespace):
|
|
642
|
+
return build_catalog(Path(args.repo_root), load_rules(args.rules))
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _dump_json(payload: dict[str, object]) -> None:
|
|
646
|
+
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
|
647
|
+
sys.stdout.write("\n")
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
if __name__ == "__main__":
|
|
651
|
+
raise SystemExit(main())
|