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/__init__.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Extract AOT table relations — the D365 equivalent of foreign keys.
|
|
2
|
+
|
|
3
|
+
AX business tables define no SQL foreign keys; the relational graph lives in the AOT
|
|
4
|
+
metadata: every ``AxTable`` (and ``AxTableExtension``) XML carries a ``<Relations>`` block
|
|
5
|
+
with the related table, the relationship type (Association/Composition), both cardinalities,
|
|
6
|
+
and the EXACT join fields (``<AxTableRelationConstraintField>``: Field ↔ RelatedField, plus
|
|
7
|
+
fixed-value constraints). This module walks one or more corpus roots (PackagesLocalDirectory
|
|
8
|
+
and/or editable source trees), parses those blocks, and stores them next to the SQL data
|
|
9
|
+
model so ``find_relations``/``get_sql_model`` can explain table relationships with the same
|
|
10
|
+
authority a foreign key would. Standard library only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sqlite3
|
|
16
|
+
import xml.etree.ElementTree as ET
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Iterator
|
|
19
|
+
|
|
20
|
+
_XSI_TYPE = "{http://www.w3.org/2001/XMLSchema-instance}type"
|
|
21
|
+
# Mirrors index_store._NON_AOT_DIRS: build outputs that contain compiled XML copies.
|
|
22
|
+
_SKIP_DIRS = {"bin", "Descriptor", "Resources", "XppMetadata", "AdditionalFiles", "obj"}
|
|
23
|
+
|
|
24
|
+
_SCHEMA = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS aot_relations(
|
|
26
|
+
table_name TEXT, relation_name TEXT, related_table TEXT,
|
|
27
|
+
relationship_type TEXT, cardinality TEXT, related_cardinality TEXT,
|
|
28
|
+
edt_relation INTEGER, source_element TEXT,
|
|
29
|
+
PRIMARY KEY(table_name, relation_name, source_element));
|
|
30
|
+
CREATE TABLE IF NOT EXISTS aot_relation_fields(
|
|
31
|
+
table_name TEXT, relation_name TEXT, kind TEXT,
|
|
32
|
+
field TEXT, related_field TEXT, fixed_value TEXT, source_edt TEXT);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS ix_aot_rel_table ON aot_relations(table_name);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS ix_aot_rel_related ON aot_relations(related_table);
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _text(node: ET.Element | None) -> str | None:
|
|
39
|
+
return node.text.strip() if node is not None and node.text else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _iter_table_files(root: Path) -> Iterator[tuple[Path, str]]:
|
|
43
|
+
"""Yield (xml_path, element_kind) for every AxTable/AxTableExtension under a corpus root."""
|
|
44
|
+
for kind in ("AxTable", "AxTableExtension"):
|
|
45
|
+
for type_dir in root.rglob(kind):
|
|
46
|
+
if not type_dir.is_dir() or type_dir.name != kind:
|
|
47
|
+
continue
|
|
48
|
+
if any(part in _SKIP_DIRS for part in type_dir.relative_to(root).parts):
|
|
49
|
+
continue
|
|
50
|
+
for xml_file in type_dir.glob("*.xml"):
|
|
51
|
+
yield xml_file, kind
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_table_relations(xml_path: Path, kind: str) -> tuple[str, str, list[dict[str, object]]]:
|
|
55
|
+
"""Parse one AxTable/AxTableExtension XML. Returns (table_name, source_element, relations).
|
|
56
|
+
|
|
57
|
+
For an extension ``CustTable.MyModel``, the relations belong to ``CustTable``.
|
|
58
|
+
"""
|
|
59
|
+
tree = ET.parse(xml_path)
|
|
60
|
+
root = tree.getroot()
|
|
61
|
+
element_name = _text(root.find("Name")) or xml_path.stem
|
|
62
|
+
table_name = element_name.split(".")[0] if kind == "AxTableExtension" else element_name
|
|
63
|
+
|
|
64
|
+
relations: list[dict[str, object]] = []
|
|
65
|
+
for rel in root.iter("AxTableRelation"):
|
|
66
|
+
constraints: list[dict[str, object]] = []
|
|
67
|
+
for constraint in rel.iter("AxTableRelationConstraint"):
|
|
68
|
+
ctype = (constraint.get(_XSI_TYPE) or "AxTableRelationConstraintField")
|
|
69
|
+
constraints.append({
|
|
70
|
+
"kind": ctype.replace("AxTableRelationConstraint", "").lower() or "field",
|
|
71
|
+
"field": _text(constraint.find("Field")),
|
|
72
|
+
"related_field": _text(constraint.find("RelatedField")),
|
|
73
|
+
"fixed_value": _text(constraint.find("Value")),
|
|
74
|
+
"source_edt": _text(constraint.find("SourceEDT")),
|
|
75
|
+
})
|
|
76
|
+
relations.append({
|
|
77
|
+
"name": _text(rel.find("Name")),
|
|
78
|
+
"related_table": _text(rel.find("RelatedTable")),
|
|
79
|
+
"relationship_type": _text(rel.find("RelationshipType")),
|
|
80
|
+
"cardinality": _text(rel.find("Cardinality")),
|
|
81
|
+
"related_cardinality": _text(rel.find("RelatedTableCardinality")),
|
|
82
|
+
"edt_relation": 1 if _text(rel.find("EDTRelation")) == "Yes" else 0,
|
|
83
|
+
"constraints": constraints,
|
|
84
|
+
})
|
|
85
|
+
return table_name, element_name, relations
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def extract_aot_relations(
|
|
89
|
+
roots: "list[str | Path]",
|
|
90
|
+
db_path: str | Path,
|
|
91
|
+
*,
|
|
92
|
+
progress: "callable[[str, int], None] | None" = None,
|
|
93
|
+
) -> dict[str, int]:
|
|
94
|
+
"""Walk every AxTable/AxTableExtension under ``roots`` and persist their relations."""
|
|
95
|
+
conn = sqlite3.connect(Path(db_path))
|
|
96
|
+
conn.executescript("DELETE FROM aot_relations; DELETE FROM aot_relation_fields;"
|
|
97
|
+
if conn.execute("SELECT 1 FROM sqlite_master WHERE name='aot_relations'").fetchone()
|
|
98
|
+
else "SELECT 1;")
|
|
99
|
+
conn.executescript(_SCHEMA)
|
|
100
|
+
|
|
101
|
+
files = relations_count = 0
|
|
102
|
+
rel_rows: list[tuple] = []
|
|
103
|
+
field_rows: list[tuple] = []
|
|
104
|
+
for root in roots:
|
|
105
|
+
root = Path(root)
|
|
106
|
+
for xml_file, kind in _iter_table_files(root):
|
|
107
|
+
try:
|
|
108
|
+
table, element, relations = parse_table_relations(xml_file, kind)
|
|
109
|
+
except OSError:
|
|
110
|
+
# Windows MAX_PATH: deep AOT paths exceed 260 chars — retry extended-length.
|
|
111
|
+
try:
|
|
112
|
+
table, element, relations = parse_table_relations(
|
|
113
|
+
Path("\\\\?\\" + str(xml_file.resolve())), kind)
|
|
114
|
+
except (OSError, ET.ParseError):
|
|
115
|
+
continue
|
|
116
|
+
except ET.ParseError:
|
|
117
|
+
continue
|
|
118
|
+
files += 1
|
|
119
|
+
for rel in relations:
|
|
120
|
+
if not rel["related_table"]:
|
|
121
|
+
continue
|
|
122
|
+
relations_count += 1
|
|
123
|
+
rel_rows.append((table, rel["name"], rel["related_table"],
|
|
124
|
+
rel["relationship_type"], rel["cardinality"],
|
|
125
|
+
rel["related_cardinality"], rel["edt_relation"], element))
|
|
126
|
+
for c in rel["constraints"]:
|
|
127
|
+
field_rows.append((table, rel["name"], c["kind"], c["field"],
|
|
128
|
+
c["related_field"], c["fixed_value"], c["source_edt"]))
|
|
129
|
+
if len(rel_rows) >= 5000:
|
|
130
|
+
conn.executemany("INSERT OR REPLACE INTO aot_relations VALUES(?,?,?,?,?,?,?,?)", rel_rows)
|
|
131
|
+
conn.executemany("INSERT INTO aot_relation_fields VALUES(?,?,?,?,?,?,?)", field_rows)
|
|
132
|
+
conn.commit()
|
|
133
|
+
rel_rows, field_rows = [], []
|
|
134
|
+
if progress:
|
|
135
|
+
progress(str(root), relations_count)
|
|
136
|
+
conn.executemany("INSERT OR REPLACE INTO aot_relations VALUES(?,?,?,?,?,?,?,?)", rel_rows)
|
|
137
|
+
conn.executemany("INSERT INTO aot_relation_fields VALUES(?,?,?,?,?,?,?)", field_rows)
|
|
138
|
+
conn.commit()
|
|
139
|
+
stats = {
|
|
140
|
+
"files_parsed": files,
|
|
141
|
+
"relations": conn.execute("SELECT COUNT(*) FROM aot_relations").fetchone()[0],
|
|
142
|
+
"constraint_fields": conn.execute("SELECT COUNT(*) FROM aot_relation_fields").fetchone()[0],
|
|
143
|
+
"tables_with_relations": conn.execute(
|
|
144
|
+
"SELECT COUNT(DISTINCT table_name) FROM aot_relations").fetchone()[0],
|
|
145
|
+
}
|
|
146
|
+
conn.close()
|
|
147
|
+
return stats
|
d365fo_agent/build.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Build / compile adapters — the top rungs of the verification ladder.
|
|
2
|
+
|
|
3
|
+
Two backends:
|
|
4
|
+
|
|
5
|
+
* :class:`BuildRunner` plans (or runs) an **MSBuild** of a Visual Studio D365 project. Kept for the
|
|
6
|
+
full-IDE flow; ``execute=False`` only emits the command.
|
|
7
|
+
* :class:`XppCompiler` drives the standalone **X++ compiler** (``xppc.exe``) directly. This is the
|
|
8
|
+
one that actually closes the "does it compile?" rung WITHOUT a full AOS/Visual Studio: ``xppc.exe``
|
|
9
|
+
ships in ``PackagesLocalDirectory/bin`` and compiles a single model against the package metadata,
|
|
10
|
+
emitting a diagnostics log we parse into structured errors/warnings. It is Windows-only (the
|
|
11
|
+
compiler is a .NET Framework assembly) and degrades gracefully — :meth:`XppCompiler.available`
|
|
12
|
+
is False when ``xppc.exe`` is not on this host, so callers report "compile rung needs a Windows
|
|
13
|
+
D365 host" instead of crashing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class BuildResult:
|
|
26
|
+
status: str
|
|
27
|
+
command: list[str]
|
|
28
|
+
returncode: int | None = None
|
|
29
|
+
stdout: str = ""
|
|
30
|
+
stderr: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BuildRunner:
|
|
34
|
+
def __init__(self, msbuild_executable: str = "msbuild.exe") -> None:
|
|
35
|
+
self.msbuild_executable = msbuild_executable
|
|
36
|
+
|
|
37
|
+
def build_project(
|
|
38
|
+
self,
|
|
39
|
+
project_path: str | Path,
|
|
40
|
+
*,
|
|
41
|
+
execute: bool = False,
|
|
42
|
+
output_path: str | Path | None = None,
|
|
43
|
+
additional_properties: dict[str, str] | None = None,
|
|
44
|
+
) -> BuildResult:
|
|
45
|
+
project_path = Path(project_path)
|
|
46
|
+
properties = dict(additional_properties or {})
|
|
47
|
+
if output_path is not None:
|
|
48
|
+
properties["OutputPath"] = str(Path(output_path))
|
|
49
|
+
command = [self.msbuild_executable, str(project_path)]
|
|
50
|
+
command.extend(f"/p:{key}={value}" for key, value in properties.items())
|
|
51
|
+
if not execute:
|
|
52
|
+
return BuildResult(status="planned", command=command)
|
|
53
|
+
|
|
54
|
+
process = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
55
|
+
return BuildResult(
|
|
56
|
+
status="succeeded" if process.returncode == 0 else "failed",
|
|
57
|
+
command=command,
|
|
58
|
+
returncode=process.returncode,
|
|
59
|
+
stdout=process.stdout,
|
|
60
|
+
stderr=process.stderr,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- X++ compiler (xppc.exe) --------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class Diagnostic:
|
|
68
|
+
severity: str # "error" | "warning"
|
|
69
|
+
category: str # e.g. "Compile", "ExternalReference", "Compile Fatal"
|
|
70
|
+
message: str
|
|
71
|
+
element: str | None = None # dynamics://… element path, when present
|
|
72
|
+
location: str | None = None # "(line,col),(line,col)", when present
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(slots=True)
|
|
76
|
+
class CompileResult:
|
|
77
|
+
status: str # "succeeded" | "failed" | "unavailable"
|
|
78
|
+
model: str
|
|
79
|
+
returncode: int | None = None
|
|
80
|
+
error_count: int = 0
|
|
81
|
+
warning_count: int = 0
|
|
82
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
83
|
+
log_path: str | None = None
|
|
84
|
+
command: list[str] = field(default_factory=list)
|
|
85
|
+
message: str | None = None
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, object]:
|
|
88
|
+
return {
|
|
89
|
+
"status": self.status,
|
|
90
|
+
"model": self.model,
|
|
91
|
+
"returncode": self.returncode,
|
|
92
|
+
"error_count": self.error_count,
|
|
93
|
+
"warning_count": self.warning_count,
|
|
94
|
+
"diagnostics": [
|
|
95
|
+
{"severity": d.severity, "category": d.category, "message": d.message,
|
|
96
|
+
"element": d.element, "location": d.location}
|
|
97
|
+
for d in self.diagnostics
|
|
98
|
+
],
|
|
99
|
+
"log_path": self.log_path,
|
|
100
|
+
"command": self.command,
|
|
101
|
+
"message": self.message,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_SUMMARY_RE = re.compile(r"^(Errors|Warnings):\s*(\d+)\s*$")
|
|
106
|
+
# "<Category words> <Severity>: <rest>" — Severity ∈ {Fatal Error, Error, Warning}
|
|
107
|
+
_DIAG_RE = re.compile(r"^(?P<category>.*?)\b(?P<sev>Fatal Error|Error|Warning):\s*(?P<rest>.*)$")
|
|
108
|
+
_ELEMENT_RE = re.compile(r"(dynamics://\S+?)(?=[:\s]|$)")
|
|
109
|
+
_LOCATION_RE = re.compile(r"\[(\([\d]+,[\d]+\),\([\d]+,[\d]+\))\]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_xppc_log(text: str) -> dict[str, object]:
|
|
113
|
+
"""Parse an ``xppc.exe`` ``-log`` file into structured diagnostics.
|
|
114
|
+
|
|
115
|
+
The log groups diagnostics between ``===`` separators, each ``<Category> <Severity>: <msg>``,
|
|
116
|
+
then a ``Errors: N`` / ``Warnings: N`` summary. Returns
|
|
117
|
+
``{error_count, warning_count, diagnostics:[Diagnostic]}``. When the summary lines are absent
|
|
118
|
+
we fall back to counting parsed diagnostics so callers always get a usable count.
|
|
119
|
+
"""
|
|
120
|
+
diagnostics: list[Diagnostic] = []
|
|
121
|
+
summary_errors: int | None = None
|
|
122
|
+
summary_warnings: int | None = None
|
|
123
|
+
|
|
124
|
+
for raw in text.replace("\x00", "").splitlines():
|
|
125
|
+
line = raw.strip()
|
|
126
|
+
if not line or set(line) <= {"="}:
|
|
127
|
+
continue
|
|
128
|
+
summary = _SUMMARY_RE.match(line)
|
|
129
|
+
if summary:
|
|
130
|
+
if summary.group(1) == "Errors":
|
|
131
|
+
summary_errors = int(summary.group(2))
|
|
132
|
+
else:
|
|
133
|
+
summary_warnings = int(summary.group(2))
|
|
134
|
+
continue
|
|
135
|
+
diag = _DIAG_RE.match(line)
|
|
136
|
+
if not diag:
|
|
137
|
+
continue
|
|
138
|
+
sev_word = diag.group("sev")
|
|
139
|
+
rest = diag.group("rest").strip()
|
|
140
|
+
severity = "error" if "Error" in sev_word else "warning"
|
|
141
|
+
category = (diag.group("category").strip() + (" Fatal" if sev_word == "Fatal Error" else "")).strip() or "Compile"
|
|
142
|
+
element_match = _ELEMENT_RE.search(rest)
|
|
143
|
+
location_match = _LOCATION_RE.search(rest)
|
|
144
|
+
diagnostics.append(Diagnostic(
|
|
145
|
+
severity=severity,
|
|
146
|
+
category=category,
|
|
147
|
+
message=rest,
|
|
148
|
+
element=element_match.group(1) if element_match else None,
|
|
149
|
+
location=location_match.group(1) if location_match else None,
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
error_count = summary_errors if summary_errors is not None else sum(1 for d in diagnostics if d.severity == "error")
|
|
153
|
+
warning_count = summary_warnings if summary_warnings is not None else sum(1 for d in diagnostics if d.severity == "warning")
|
|
154
|
+
return {"error_count": error_count, "warning_count": warning_count, "diagnostics": diagnostics}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class XppCompiler:
|
|
158
|
+
"""Drive ``xppc.exe`` to compile a single model against the package metadata."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, packages_root: str | Path, xppc_path: str | Path | None = None) -> None:
|
|
161
|
+
self.packages_root = Path(packages_root)
|
|
162
|
+
self.xppc_path = Path(xppc_path) if xppc_path else (self.packages_root / "bin" / "xppc.exe")
|
|
163
|
+
|
|
164
|
+
def available(self) -> bool:
|
|
165
|
+
"""True iff the X++ compiler is present on this host (it is Windows-only)."""
|
|
166
|
+
return self.xppc_path.exists()
|
|
167
|
+
|
|
168
|
+
def compile_model(
|
|
169
|
+
self,
|
|
170
|
+
model: str,
|
|
171
|
+
*,
|
|
172
|
+
output_path: str | Path,
|
|
173
|
+
log_path: str | Path,
|
|
174
|
+
reference_folder: str | Path | None = None,
|
|
175
|
+
appchecker: bool = False,
|
|
176
|
+
xref_file: str | Path | None = None,
|
|
177
|
+
timeout: int = 1800,
|
|
178
|
+
) -> CompileResult:
|
|
179
|
+
"""Compile ``model`` and return structured diagnostics.
|
|
180
|
+
|
|
181
|
+
``appchecker=True`` additionally runs the Appchecker (best-practice) rules. Returns a
|
|
182
|
+
``CompileResult`` with ``status="unavailable"`` (not an exception) when ``xppc.exe`` is not
|
|
183
|
+
on this host, so the caller can report the rung as "needs a Windows D365 host".
|
|
184
|
+
"""
|
|
185
|
+
if not self.available():
|
|
186
|
+
return CompileResult(
|
|
187
|
+
status="unavailable", model=model,
|
|
188
|
+
message=f"xppc.exe not found at {self.xppc_path} — the compile rung needs a Windows D365 host "
|
|
189
|
+
"with PackagesLocalDirectory/bin present.",
|
|
190
|
+
)
|
|
191
|
+
output_path = Path(output_path)
|
|
192
|
+
log_path = Path(log_path)
|
|
193
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
reference_folder = Path(reference_folder) if reference_folder else self.packages_root
|
|
196
|
+
|
|
197
|
+
command = [
|
|
198
|
+
str(self.xppc_path),
|
|
199
|
+
f"-metadata={self.packages_root}",
|
|
200
|
+
f"-modelmodule={model}",
|
|
201
|
+
f"-output={output_path}",
|
|
202
|
+
f"-referenceFolder={reference_folder}",
|
|
203
|
+
f"-log={log_path}",
|
|
204
|
+
]
|
|
205
|
+
if appchecker:
|
|
206
|
+
command.append("-RunAppcheckerRules")
|
|
207
|
+
if xref_file:
|
|
208
|
+
command.extend(["-xref", f"-xreffile={Path(xref_file)}"])
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
process = subprocess.run(command, capture_output=True, text=True, check=False, timeout=timeout)
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
return CompileResult(status="failed", model=model, command=command,
|
|
214
|
+
message=f"xppc.exe timed out after {timeout}s.")
|
|
215
|
+
|
|
216
|
+
parsed = {"error_count": 0, "warning_count": 0, "diagnostics": []}
|
|
217
|
+
if log_path.exists():
|
|
218
|
+
parsed = parse_xppc_log(log_path.read_text(encoding="utf-8", errors="ignore"))
|
|
219
|
+
error_count = int(parsed["error_count"])
|
|
220
|
+
# xppc returns non-zero on failure; treat a clean log + zero exit as success.
|
|
221
|
+
status = "succeeded" if (process.returncode == 0 and error_count == 0) else "failed"
|
|
222
|
+
return CompileResult(
|
|
223
|
+
status=status, model=model, returncode=process.returncode,
|
|
224
|
+
error_count=error_count, warning_count=int(parsed["warning_count"]),
|
|
225
|
+
diagnostics=list(parsed["diagnostics"]), log_path=str(log_path), command=command,
|
|
226
|
+
message=(process.stdout.strip().splitlines()[-1] if process.stdout.strip() else None),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def compile_overlay(
|
|
230
|
+
self,
|
|
231
|
+
model: str,
|
|
232
|
+
overlays: list[tuple[str, str]],
|
|
233
|
+
*,
|
|
234
|
+
output_path: str | Path,
|
|
235
|
+
log_path: str | Path,
|
|
236
|
+
appchecker: bool = False,
|
|
237
|
+
timeout: int = 1800,
|
|
238
|
+
) -> CompileResult:
|
|
239
|
+
"""Compile freshly-generated artifacts IN CONTEXT — closes the generate->compile loop.
|
|
240
|
+
|
|
241
|
+
``overlays`` is a list of ``(relative_path, content)`` where ``relative_path`` is under the
|
|
242
|
+
PackagesLocalDirectory (e.g. ``BABGeneralLedger/BABGeneralLedger/AxClass/Foo.xml``). Each is
|
|
243
|
+
temporarily written into the PLD so the model compiles WITH the new artifact, then the PLD is
|
|
244
|
+
**always restored** (added files removed, overwritten files put back) via try/finally — even
|
|
245
|
+
on error/timeout. This proves generated X++ actually compiles before it is claimed done.
|
|
246
|
+
"""
|
|
247
|
+
if not self.available():
|
|
248
|
+
return CompileResult(
|
|
249
|
+
status="unavailable", model=model,
|
|
250
|
+
message=f"xppc.exe not found at {self.xppc_path} — the compile rung needs a Windows D365 host.",
|
|
251
|
+
)
|
|
252
|
+
if not overlays:
|
|
253
|
+
return CompileResult(status="failed", model=model, message="No artifacts to overlay/compile.")
|
|
254
|
+
backups: list[tuple[Path, bytes | None]] = []
|
|
255
|
+
try:
|
|
256
|
+
for relative_path, content in overlays:
|
|
257
|
+
target = self.packages_root / relative_path
|
|
258
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
backups.append((target, target.read_bytes() if target.exists() else None))
|
|
260
|
+
target.write_text(content, encoding="utf-8")
|
|
261
|
+
# xppc ALSO (re)writes compiled metadata under <package>/XppMetadata/<model>/... as a
|
|
262
|
+
# byproduct; snapshot it so the finally block removes/restores it too (it does not yet
|
|
263
|
+
# exist for a brand-new artifact), leaving the PLD exactly as we found it.
|
|
264
|
+
meta = self._xppmetadata_path(relative_path)
|
|
265
|
+
if meta is not None:
|
|
266
|
+
backups.append((meta, meta.read_bytes() if meta.exists() else None))
|
|
267
|
+
result = self.compile_model(model, output_path=output_path, log_path=log_path, timeout=timeout, appchecker=appchecker)
|
|
268
|
+
result.message = (result.message or "") + f" [compiled with {len(overlays)} overlaid artifact(s), PLD restored]"
|
|
269
|
+
return result
|
|
270
|
+
finally:
|
|
271
|
+
for target, original in reversed(backups): # restore in reverse order
|
|
272
|
+
if original is None:
|
|
273
|
+
if target.exists():
|
|
274
|
+
target.unlink()
|
|
275
|
+
else:
|
|
276
|
+
target.write_bytes(original)
|
|
277
|
+
|
|
278
|
+
def _xppmetadata_path(self, relative_path: str) -> Path | None:
|
|
279
|
+
"""The compiled-metadata counterpart xppc writes for a source artifact: a source at
|
|
280
|
+
``<package>/<model>/<AxType>/<name>.xml`` maps to ``<package>/XppMetadata/<model>/<AxType>/<name>.xml``."""
|
|
281
|
+
parts = relative_path.replace("\\", "/").split("/")
|
|
282
|
+
if len(parts) < 4:
|
|
283
|
+
return None
|
|
284
|
+
package, model = parts[0], parts[1]
|
|
285
|
+
return self.packages_root.joinpath(package, "XppMetadata", model, *parts[2:])
|