d365fo-mcp 1.8.3 → 1.8.5
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.
- package/dist/scripts/build-database.js +16 -1
- package/dist/scripts/build-fts.js +16 -1
- package/dist/scripts/extract-metadata.js +16 -1
- package/dist/server/toolSchemas/getWorkspaceInfo.js +2 -2
- package/dist/tools/createD365File.js +20 -0
- package/dist/tools/createLabel.js +21 -0
- package/dist/tools/d365foFileOpSpecs.js +11 -4
- package/dist/tools/generateSmartForm.js +10 -0
- package/dist/tools/generateSmartReport.js +11 -0
- package/dist/tools/generateSmartTable.js +11 -0
- package/dist/tools/getLabelInfo.js +31 -0
- package/dist/tools/modifyD365File.d.ts +1 -1
- package/dist/tools/modifyD365File.js +91 -9
- package/dist/tools/prefixDiagnostics.d.ts +38 -0
- package/dist/tools/prefixDiagnostics.js +89 -0
- package/dist/tools/toolHandler.js +33 -17
- package/dist/tools/writeAnchorGuard.d.ts +41 -0
- package/dist/tools/writeAnchorGuard.js +44 -0
- package/dist/tools/xppKnowledge.js +4 -1
- package/dist/utils/configManager.d.ts +30 -0
- package/dist/utils/configManager.js +81 -0
- package/dist/utils/crossModelWriteGuard.d.ts +45 -11
- package/dist/utils/crossModelWriteGuard.js +64 -25
- package/dist/utils/labelDiskCheck.d.ts +24 -0
- package/dist/utils/labelDiskCheck.js +88 -0
- package/dist/utils/loadEnv.d.ts +15 -0
- package/dist/utils/loadEnv.js +76 -1
- package/dist/utils/modelClassifier.d.ts +7 -4
- package/dist/utils/modelClassifier.js +12 -9
- package/dist/utils/modelPrefixInference.d.ts +25 -10
- package/dist/utils/modelPrefixInference.js +123 -21
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/utils/loadEnv.ts
|
|
2
2
|
import dotenv from "dotenv";
|
|
3
|
-
import { existsSync as existsSync2 } from "fs";
|
|
3
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
4
4
|
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
|
|
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
|
|
|
606
606
|
|
|
607
607
|
// src/utils/loadEnv.ts
|
|
608
608
|
var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
|
|
609
|
+
var WRITE_POLICY_VARS = [
|
|
610
|
+
"D365FO_ALLOW_CROSS_MODEL_WRITE",
|
|
611
|
+
"D365FO_CROSS_MODEL_WRITE_MODELS"
|
|
612
|
+
];
|
|
613
|
+
var writePolicySource = null;
|
|
614
|
+
var writePolicyStamp = "";
|
|
609
615
|
function installRootFrom(callerDir) {
|
|
610
616
|
let dir = resolve2(callerDir, "..");
|
|
611
617
|
for (let up = 0; up < 3; up++) {
|
|
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
|
|
|
621
627
|
const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
|
|
622
628
|
const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
|
|
623
629
|
const fromRealEnv = new Set(Object.keys(process.env));
|
|
630
|
+
writePolicySource = {
|
|
631
|
+
envPath,
|
|
632
|
+
pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
|
|
633
|
+
};
|
|
634
|
+
try {
|
|
635
|
+
writePolicyStamp = String(statSync(envPath).mtimeMs);
|
|
636
|
+
} catch {
|
|
637
|
+
writePolicyStamp = "-";
|
|
638
|
+
}
|
|
624
639
|
const result = dotenv.config({ path: envPath, quiet: true });
|
|
625
640
|
if (result.error && !process.env.ENV_FILE) {
|
|
626
641
|
dotenv.config({ quiet: true });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/utils/loadEnv.ts
|
|
2
2
|
import dotenv from "dotenv";
|
|
3
|
-
import { existsSync as existsSync2 } from "fs";
|
|
3
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
4
4
|
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
|
|
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
|
|
|
606
606
|
|
|
607
607
|
// src/utils/loadEnv.ts
|
|
608
608
|
var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
|
|
609
|
+
var WRITE_POLICY_VARS = [
|
|
610
|
+
"D365FO_ALLOW_CROSS_MODEL_WRITE",
|
|
611
|
+
"D365FO_CROSS_MODEL_WRITE_MODELS"
|
|
612
|
+
];
|
|
613
|
+
var writePolicySource = null;
|
|
614
|
+
var writePolicyStamp = "";
|
|
609
615
|
function installRootFrom(callerDir) {
|
|
610
616
|
let dir = resolve2(callerDir, "..");
|
|
611
617
|
for (let up = 0; up < 3; up++) {
|
|
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
|
|
|
621
627
|
const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
|
|
622
628
|
const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
|
|
623
629
|
const fromRealEnv = new Set(Object.keys(process.env));
|
|
630
|
+
writePolicySource = {
|
|
631
|
+
envPath,
|
|
632
|
+
pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
|
|
633
|
+
};
|
|
634
|
+
try {
|
|
635
|
+
writePolicyStamp = String(statSync(envPath).mtimeMs);
|
|
636
|
+
} catch {
|
|
637
|
+
writePolicyStamp = "-";
|
|
638
|
+
}
|
|
624
639
|
const result = dotenv.config({ path: envPath, quiet: true });
|
|
625
640
|
if (result.error && !process.env.ENV_FILE) {
|
|
626
641
|
dotenv.config({ quiet: true });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/utils/loadEnv.ts
|
|
2
2
|
import dotenv from "dotenv";
|
|
3
|
-
import { existsSync as existsSync2 } from "fs";
|
|
3
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
4
4
|
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
|
|
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
|
|
|
606
606
|
|
|
607
607
|
// src/utils/loadEnv.ts
|
|
608
608
|
var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
|
|
609
|
+
var WRITE_POLICY_VARS = [
|
|
610
|
+
"D365FO_ALLOW_CROSS_MODEL_WRITE",
|
|
611
|
+
"D365FO_CROSS_MODEL_WRITE_MODELS"
|
|
612
|
+
];
|
|
613
|
+
var writePolicySource = null;
|
|
614
|
+
var writePolicyStamp = "";
|
|
609
615
|
function installRootFrom(callerDir) {
|
|
610
616
|
let dir = resolve2(callerDir, "..");
|
|
611
617
|
for (let up = 0; up < 3; up++) {
|
|
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
|
|
|
621
627
|
const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
|
|
622
628
|
const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
|
|
623
629
|
const fromRealEnv = new Set(Object.keys(process.env));
|
|
630
|
+
writePolicySource = {
|
|
631
|
+
envPath,
|
|
632
|
+
pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
|
|
633
|
+
};
|
|
634
|
+
try {
|
|
635
|
+
writePolicyStamp = String(statSync(envPath).mtimeMs);
|
|
636
|
+
} catch {
|
|
637
|
+
writePolicyStamp = "-";
|
|
638
|
+
}
|
|
624
639
|
const result = dotenv.config({ path: envPath, quiet: true });
|
|
625
640
|
if (result.error && !process.env.ENV_FILE) {
|
|
626
641
|
dotenv.config({ quiet: true });
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export const getWorkspaceInfoTool = {
|
|
7
7
|
name: 'get_workspace_info',
|
|
8
|
-
description: `ALWAYS call FIRST at session start. Returns model name, package path, framework directory, project path, environment type, and EXTENSION_PREFIX. Flags placeholder model names and missing prefix.
|
|
8
|
+
description: `ALWAYS call FIRST at session start. Returns model name, package path, framework directory, project path, environment type, and EXTENSION_PREFIX. Flags placeholder model names and missing prefix. projectName/projectPath ONLY when the USER changed project. This is the authoritative source for target model — not search results.`,
|
|
9
9
|
inputSchema: {
|
|
10
10
|
type: 'object',
|
|
11
11
|
properties: {
|
|
12
12
|
projectName: {
|
|
13
13
|
type: 'string',
|
|
14
|
-
description: '
|
|
14
|
+
description: 'Only when the USER says "switch to <project>". Just the model name, e.g. "ContosoEDS"; path resolved from D365FO_SOLUTIONS_PATH. NOT a way to reach another model — reads span every model already, writes stay in the workspace model.',
|
|
15
15
|
},
|
|
16
16
|
projectPath: {
|
|
17
17
|
type: 'string',
|
|
@@ -10,6 +10,7 @@ import { getConfigManager, fallbackPackagePath } from '../utils/configManager.js
|
|
|
10
10
|
import { describePackagesRootScan } from '../utils/packagesRoot.js';
|
|
11
11
|
import { registerCustomModel, resolveObjectPrefix, applyObjectPrefix, getObjectSuffix, applyObjectSuffix, getExtensionNamingStyle } from '../utils/modelClassifier.js';
|
|
12
12
|
import { PackageResolver } from '../utils/packageResolver.js';
|
|
13
|
+
import { crossModelWriteRefusal } from '../utils/crossModelWriteGuard.js';
|
|
13
14
|
import { ensureXppDocComment, ensureBlankLineBeforeClosingBrace } from '../utils/xppDocGen.js';
|
|
14
15
|
import { reindentXppSource } from '../utils/xppFormat.js';
|
|
15
16
|
import { decodeXmlEntitiesFromXppSource } from './modifyD365File.js';
|
|
@@ -3796,6 +3797,25 @@ export async function handleCreateD365File(request, context) {
|
|
|
3796
3797
|
isError: true,
|
|
3797
3798
|
};
|
|
3798
3799
|
}
|
|
3800
|
+
// Cross-model guard: creating INTO a custom model other than the one this
|
|
3801
|
+
// workspace targets is the same mistake as modifying one — the object lands
|
|
3802
|
+
// outside this project's version control and inside code other models inherit.
|
|
3803
|
+
// `actualModelName` is what the write will actually use (caller's modelName, or
|
|
3804
|
+
// the workspace's), so the check sits after every fallback has been applied.
|
|
3805
|
+
const crossModelCreateRefusal = crossModelWriteRefusal({
|
|
3806
|
+
objectName: args.objectName,
|
|
3807
|
+
objectType: args.objectType,
|
|
3808
|
+
owningModel: actualModelName,
|
|
3809
|
+
activeModel: getConfigManager().getWriteAnchorModel() ?? '',
|
|
3810
|
+
toolSwitchedModel: getConfigManager().getToolProjectSwitch()?.forcedModel ?? null,
|
|
3811
|
+
action: 'create',
|
|
3812
|
+
});
|
|
3813
|
+
if (crossModelCreateRefusal) {
|
|
3814
|
+
return {
|
|
3815
|
+
content: [{ type: 'text', text: crossModelCreateRefusal }],
|
|
3816
|
+
isError: true,
|
|
3817
|
+
};
|
|
3818
|
+
}
|
|
3799
3819
|
// Apply extension prefix to object name
|
|
3800
3820
|
const objectPrefix = resolveObjectPrefix(actualModelName);
|
|
3801
3821
|
const namingStyle = getExtensionNamingStyle();
|
|
@@ -17,6 +17,7 @@ import * as path from 'path';
|
|
|
17
17
|
import { getConfigManager } from '../utils/configManager.js';
|
|
18
18
|
import { defaultPackagesRoot } from '../utils/packagesRoot.js';
|
|
19
19
|
import { PackageResolver } from '../utils/packageResolver.js';
|
|
20
|
+
import { crossModelWriteRefusal } from '../utils/crossModelWriteGuard.js';
|
|
20
21
|
import { detectEol } from '../utils/eolUtils.js';
|
|
21
22
|
import { isExtensionLabelFile } from '../metadata/labelParser.js';
|
|
22
23
|
import { ProjectFileManager, ProjectFileFinder } from './createD365File.js';
|
|
@@ -445,6 +446,26 @@ export async function createLabelTool(request, context) {
|
|
|
445
446
|
resolvedPackagePath = packagePath || configManager.getPackagePath() || defaultPackagesRoot();
|
|
446
447
|
resolvedPackageName = model;
|
|
447
448
|
}
|
|
449
|
+
// Cross-model guard: a label file belongs to exactly one model, so writing a
|
|
450
|
+
// label into another model's file has the same consequences as editing that
|
|
451
|
+
// model's objects — and labels are usually the first thing an agent adds when
|
|
452
|
+
// it drifts into the wrong model. `model` is the model directory the write
|
|
453
|
+
// resolves to, so this compares the real target, not the caller's intent.
|
|
454
|
+
const crossModelLabelRefusal = crossModelWriteRefusal({
|
|
455
|
+
objectName: `@${args.labelFileId}:${args.labelId}`,
|
|
456
|
+
objectType: 'label',
|
|
457
|
+
owningModel: model,
|
|
458
|
+
owningPackage: resolvedPackageName,
|
|
459
|
+
activeModel: configManager.getWriteAnchorModel() ?? '',
|
|
460
|
+
toolSwitchedModel: configManager.getToolProjectSwitch()?.forcedModel ?? null,
|
|
461
|
+
action: 'create',
|
|
462
|
+
});
|
|
463
|
+
if (crossModelLabelRefusal) {
|
|
464
|
+
return {
|
|
465
|
+
content: [{ type: 'text', text: crossModelLabelRefusal }],
|
|
466
|
+
isError: true,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
448
469
|
const modelDir = path.join(resolvedPackagePath, resolvedPackageName, model);
|
|
449
470
|
const axLabelDir = path.join(modelDir, 'AxLabelFile');
|
|
450
471
|
const labelResourcesDir = path.join(axLabelDir, 'LabelResources');
|
|
@@ -73,7 +73,11 @@ export const D365FO_FILE_PARAM_SPECS = {
|
|
|
73
73
|
fieldMandatory: { type: 'boolean', description: 'Mark the field Mandatory=Yes.' },
|
|
74
74
|
fieldLabel: { type: 'string', description: 'Field label.' },
|
|
75
75
|
fieldHelpText: { type: 'string', description: 'Field help text.' },
|
|
76
|
-
fieldEnumType: {
|
|
76
|
+
fieldEnumType: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'Enum name for an enum-typed field. On add-field this is all an enum field needs — ' +
|
|
79
|
+
'it writes AxTableFieldEnum + EnumType, no EDT.',
|
|
80
|
+
},
|
|
77
81
|
fieldStringSize: { type: 'string', description: 'String size to set on a string-typed field.' },
|
|
78
82
|
dataField: {
|
|
79
83
|
type: 'string',
|
|
@@ -267,9 +271,12 @@ export const D365FO_FILE_OP_SPECS = {
|
|
|
267
271
|
},
|
|
268
272
|
'add-field': {
|
|
269
273
|
required: ['fieldName'],
|
|
270
|
-
optional: ['fieldType', 'fieldBaseType', 'fieldMandatory', 'fieldLabel', 'dataField', 'dataSource', 'fieldGroupName'],
|
|
271
|
-
mutationOneOf: ['fieldType', 'dataField'],
|
|
272
|
-
note: '
|
|
274
|
+
optional: ['fieldType', 'fieldBaseType', 'fieldEnumType', 'fieldMandatory', 'fieldLabel', 'dataField', 'dataSource', 'fieldGroupName'],
|
|
275
|
+
mutationOneOf: ['fieldType', 'fieldEnumType', 'dataField'],
|
|
276
|
+
note: 'Enum field: pass fieldEnumType="<enum name>" and NO fieldType — an enum-typed table field ' +
|
|
277
|
+
'is an AxTableFieldEnum with an EnumType and needs no EDT. (fieldType is the EDT name here, ' +
|
|
278
|
+
'never an XML element name like "AxTableFieldEnum".) ' +
|
|
279
|
+
'Table/table-extension: otherwise fieldType (EDT) is REQUIRED. data-entity-extension: pass dataField AND ' +
|
|
273
280
|
'dataSource instead — BOTH, or nothing is written; a mapped field has no EDT of its own, it points ' +
|
|
274
281
|
'at dataField on the entity data source dataSource. fieldGroupName is optional and only applies to ' +
|
|
275
282
|
'a data-entity-extension: it appends the field to that BASE-entity field group (shipped extensions ' +
|
|
@@ -21,6 +21,7 @@ import { methodStubsForPattern, injectMethodStubs } from '../knowledge/formPatte
|
|
|
21
21
|
import { findBaseFormXml } from './modifyD365File.js';
|
|
22
22
|
import { getFieldControlMap, getTableTitleField } from '../utils/fieldControlTypes.js';
|
|
23
23
|
import { lookupSymbolNocase } from '../utils/symbolLookup.js';
|
|
24
|
+
import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
|
|
24
25
|
/**
|
|
25
26
|
* Symbol types a form datasource may bind to. Views are indexed as 'view'
|
|
26
27
|
* (see symbolIndex.indexViews) and are legal in <Table> just like tables.
|
|
@@ -560,6 +561,15 @@ export async function handleGenerateSmartForm(args, symbolIndex) {
|
|
|
560
561
|
if (finalName !== name) {
|
|
561
562
|
console.log(`[generateSmartForm] Applied naming: ${name} → ${finalName}`);
|
|
562
563
|
}
|
|
564
|
+
// See generateSmartTable: the resolved model follows the ACTIVE project, the
|
|
565
|
+
// write anchor does not. Checked before the first byte is written.
|
|
566
|
+
const anchorRefusal = scaffoldWriteRefusalResult({
|
|
567
|
+
objectName: finalName,
|
|
568
|
+
objectType: 'form',
|
|
569
|
+
targetModel: resolvedModel,
|
|
570
|
+
});
|
|
571
|
+
if (anchorRefusal)
|
|
572
|
+
return anchorRefusal;
|
|
563
573
|
// Generate XML: clone an existing form (preferred) or build from a template.
|
|
564
574
|
// Without an explicit pattern, default to the majority pattern mined from
|
|
565
575
|
// standard models (property_stats), falling back to SimpleList.
|
|
@@ -32,6 +32,7 @@ import { resolveObjectPrefix, applyObjectPrefix, getObjectSuffix, applyObjectSuf
|
|
|
32
32
|
import { extractModelFromProject, findProjectInSolution } from '../utils/projectUtils.js';
|
|
33
33
|
import { normalizeD365Xml } from '../utils/d365XmlNormalizer.js';
|
|
34
34
|
import { canonicalSymbolName, lookupSymbolNocase } from '../utils/symbolLookup.js';
|
|
35
|
+
import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
|
|
35
36
|
export const generateSmartReportTool = {
|
|
36
37
|
name: 'generate_smart_report',
|
|
37
38
|
description: `🎨 AI-driven SSRS report generation — creates up to 7 D365FO objects in one call.
|
|
@@ -221,6 +222,16 @@ export async function handleGenerateSmartReport(args, symbolIndex, bridge) {
|
|
|
221
222
|
finalName = applyObjectSuffix(finalName, objectSuffix);
|
|
222
223
|
if (finalName !== name)
|
|
223
224
|
log(`Applied naming: ${name} → ${finalName}`);
|
|
225
|
+
// See generateSmartTable: the resolved model follows the ACTIVE project, the
|
|
226
|
+
// write anchor does not. A report scaffold writes several objects at once, so
|
|
227
|
+
// this is checked before any of them exists.
|
|
228
|
+
const anchorRefusal = scaffoldWriteRefusalResult({
|
|
229
|
+
objectName: finalName,
|
|
230
|
+
objectType: 'report',
|
|
231
|
+
targetModel: resolvedModel,
|
|
232
|
+
});
|
|
233
|
+
if (anchorRefusal)
|
|
234
|
+
return anchorRefusal;
|
|
224
235
|
// Derived object names
|
|
225
236
|
const tmpTableName = `${finalName}Tmp`;
|
|
226
237
|
const contractClassName = `${finalName}Contract`;
|
|
@@ -13,6 +13,7 @@ import { ProjectFileManager } from './createD365File.js';
|
|
|
13
13
|
import { extractModelFromProject, findProjectInSolution } from '../utils/projectUtils.js';
|
|
14
14
|
import { normalizeD365Xml } from '../utils/d365XmlNormalizer.js';
|
|
15
15
|
import { lookupSymbolNocase } from '../utils/symbolLookup.js';
|
|
16
|
+
import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
|
|
16
17
|
export const generateSmartTableTool = {
|
|
17
18
|
name: 'generate_smart_table',
|
|
18
19
|
description: 'Generate AxTable XML with AI-driven field/index/relation suggestions based on indexed patterns. Can copy structure from existing tables, analyze table group patterns, or use field hints.',
|
|
@@ -559,6 +560,16 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
|
|
|
559
560
|
if (finalName !== name) {
|
|
560
561
|
console.log(`[generateSmartTable] Applied naming: ${name} → ${finalName}`);
|
|
561
562
|
}
|
|
563
|
+
// Where this scaffold would land, checked before anything is written. The
|
|
564
|
+
// model above comes from the ACTIVE project, which a get_workspace_info switch
|
|
565
|
+
// moves; writes stay anchored to the model the workspace resolved on its own.
|
|
566
|
+
const anchorRefusal = scaffoldWriteRefusalResult({
|
|
567
|
+
objectName: finalName,
|
|
568
|
+
objectType: 'table',
|
|
569
|
+
targetModel: resolvedModel,
|
|
570
|
+
});
|
|
571
|
+
if (anchorRefusal)
|
|
572
|
+
return anchorRefusal;
|
|
562
573
|
// Generate standard methods (find, exist) based on primary key fields
|
|
563
574
|
const generatedMethods = [];
|
|
564
575
|
if (requestedMethods && requestedMethods.length > 0) {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { formatLabelReference } from '../utils/labelReference.js';
|
|
8
|
+
import { labelMissingOnDisk } from '../utils/labelDiskCheck.js';
|
|
8
9
|
const GetLabelInfoArgsSchema = z.object({
|
|
9
10
|
labelId: z
|
|
10
11
|
.string()
|
|
@@ -96,6 +97,36 @@ export async function getLabelInfoTool(request, context) {
|
|
|
96
97
|
// #33/#41: never emit `@SYS:@SYS67433` — an id that already carries its label
|
|
97
98
|
// file id is a complete reference, and xppbp rejects the doubled form.
|
|
98
99
|
const ref = formatLabelReference(first.labelFileId, labelId);
|
|
100
|
+
// An index row is not proof the label exists. Confirm against the .label.txt
|
|
101
|
+
// before handing back a reference the caller will paste into XML — a stale row
|
|
102
|
+
// otherwise surfaces as `Unknown label` at build time, several steps later.
|
|
103
|
+
//
|
|
104
|
+
// Every model, including Microsoft's: gating this on isCustomModel() looks
|
|
105
|
+
// right and is not, because isStandardModel() is defined as "not custom" and
|
|
106
|
+
// an unrecognised model therefore reads as Microsoft's. The shared core model
|
|
107
|
+
// whose phantom label started all this is exactly such a model, so the gate
|
|
108
|
+
// would have switched the check off in the one place it had to fire. What
|
|
109
|
+
// makes the big files affordable is the read budget in labelMissingOnDisk:
|
|
110
|
+
// the @SYS sweep that measured 17 s now gives up at 218 ms with no verdict.
|
|
111
|
+
const indexedPaths = symbolIndex
|
|
112
|
+
.getLabelFilePaths(first.labelFileId, first.model)
|
|
113
|
+
.map(p => p.filePath);
|
|
114
|
+
if (await labelMissingOnDisk(labelId, indexedPaths)) {
|
|
115
|
+
return {
|
|
116
|
+
content: [{
|
|
117
|
+
type: 'text',
|
|
118
|
+
text: `⚠️ Label "${ref}" is in the symbol index but NOT in its label file on disk — ` +
|
|
119
|
+
`treat it as NOT existing.\n` +
|
|
120
|
+
`Checked: ${indexedPaths.join(', ')}\n\n` +
|
|
121
|
+
`The index is ahead of the file system (a rolled-back run, a rebuild outside this ` +
|
|
122
|
+
`server, or a checkout). Using this reference compiles to a best-practice error ` +
|
|
123
|
+
`"Unknown label '${ref}'".\n\n` +
|
|
124
|
+
`Create it: labels(action="create", labelId="${labelId}", labelFileId="${first.labelFileId}", ` +
|
|
125
|
+
`model="${first.model}", translations=[{language:"en-US", text:"…"}])`,
|
|
126
|
+
}],
|
|
127
|
+
isError: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
99
130
|
const lines = [
|
|
100
131
|
`Label: ${ref}`,
|
|
101
132
|
`Model: ${first.model} | LabelFile: ${first.labelFileId}`,
|
|
@@ -167,7 +167,7 @@ export declare function modifyD365FileTool(request: CallToolRequest, context: Xp
|
|
|
167
167
|
*
|
|
168
168
|
* Idempotent: a name that already carries the prefix — in either the underscore
|
|
169
169
|
* or the bare form, case-insensitively — is left untouched, so an agent that
|
|
170
|
-
* prefixes by hand does not end up with
|
|
170
|
+
* prefixes by hand does not end up with DEMO_DEMO_Foo.
|
|
171
171
|
*/
|
|
172
172
|
export declare function applyExtensionMemberPrefix(args: Record<string, any>, objectType: string, operation: string, modelName: string): string;
|
|
173
173
|
/**
|
|
@@ -1126,7 +1126,7 @@ const ModifyD365FileArgsSchema = z.object({
|
|
|
1126
1126
|
fieldMandatory: z.boolean().optional().describe('Is field mandatory'),
|
|
1127
1127
|
fieldLabel: z.string().optional().describe('Field label'),
|
|
1128
1128
|
fieldHelpText: z.string().optional().describe('Field help text (modify-field).'),
|
|
1129
|
-
fieldEnumType: z.string().optional().describe('Enum name
|
|
1129
|
+
fieldEnumType: z.string().optional().describe('Enum name for an enum-typed field. On add-field this replaces fieldType entirely — it writes AxTableFieldEnum + EnumType and needs no EDT. Also settable later with modify-field.'),
|
|
1130
1130
|
fieldStringSize: z.string().optional().describe('String size to set on the field (modify-field, for string-typed fields).'),
|
|
1131
1131
|
fields: z.array(z.object({
|
|
1132
1132
|
name: z.string(),
|
|
@@ -1457,14 +1457,17 @@ export async function modifyD365FileTool(request, context) {
|
|
|
1457
1457
|
// Ownership comes from the path's <Model> segment, not the <Package> segment
|
|
1458
1458
|
// used above: one package can carry several models.
|
|
1459
1459
|
const owningModel = containment.modelSegment ?? null;
|
|
1460
|
-
|
|
1460
|
+
// The write ANCHOR, not the active model: a get_workspace_info project switch
|
|
1461
|
+
// moves reads, and must not move what this guard measures writes against.
|
|
1462
|
+
const activeModel = getConfigManager().getWriteAnchorModel() ?? '';
|
|
1461
1463
|
const crossModelRefusal = crossModelWriteRefusal({
|
|
1462
1464
|
objectName,
|
|
1463
1465
|
objectType,
|
|
1464
1466
|
owningModel,
|
|
1465
1467
|
owningPackage: containment.packageSegment ?? resolvedModelFromPath,
|
|
1466
1468
|
activeModel,
|
|
1467
|
-
|
|
1469
|
+
toolSwitchedModel: getConfigManager().getToolProjectSwitch()?.forcedModel ?? null,
|
|
1470
|
+
action: 'modify',
|
|
1468
1471
|
existingExtensions: findExtensionsInModel(symbolIndex, baseObjectOf(objectName, objectType), activeModel),
|
|
1469
1472
|
});
|
|
1470
1473
|
if (crossModelRefusal) {
|
|
@@ -1682,7 +1685,31 @@ export async function modifyD365FileTool(request, context) {
|
|
|
1682
1685
|
// (the mapped-field path above has no fieldType at all), so the type-specific half
|
|
1683
1686
|
// of the contract is enforced here instead of silently falling through to a null
|
|
1684
1687
|
// bridge result and a generic "required parameters may be missing".
|
|
1685
|
-
|
|
1688
|
+
const enumTypeArg = args.fieldEnumType?.trim() || undefined;
|
|
1689
|
+
// fieldType is an EDT NAME here. In `create` the sibling key fields[].fieldType is
|
|
1690
|
+
// the XML element name ("AxTableFieldEnum"), and that collision gets carried over
|
|
1691
|
+
// into add-field, where it used to be accepted and produce a bare AxTableFieldString
|
|
1692
|
+
// referencing a non-existent EDT — a wrong field, discovered only at build time.
|
|
1693
|
+
// Anchored on the metamodel's own container names, not on "starts with Ax
|
|
1694
|
+
// and contains Field": that broader shape also rejected any legitimate EDT
|
|
1695
|
+
// whose name happens to read that way, and refusing a valid EDT is the
|
|
1696
|
+
// same class of wrong answer this check exists to prevent.
|
|
1697
|
+
if (args.fieldType && /^Ax(Table|View|Query|Map|DataEntityView)[A-Za-z]*Field[A-Za-z0-9]*$/i.test(args.fieldType)) {
|
|
1698
|
+
return {
|
|
1699
|
+
content: [{
|
|
1700
|
+
type: 'text',
|
|
1701
|
+
text: `❌ fieldType="${args.fieldType}" is an XML element name, not an EDT — nothing was written.\n` +
|
|
1702
|
+
`On add-field, fieldType is the EDT NAME (e.g. "TransDate", "ItemId"); the XML element is ` +
|
|
1703
|
+
`chosen from fieldBaseType.\n` +
|
|
1704
|
+
`For an enum field pass fieldEnumType="<enum name>" instead — no EDT is needed:\n` +
|
|
1705
|
+
` d365fo_file(action="modify", objectType="${objectType}", objectName="…", ` +
|
|
1706
|
+
`operation="add-field", fieldName="${args.fieldName ?? 'MyField'}", fieldEnumType="MyEnum")\n` +
|
|
1707
|
+
`\n${renderOpSpec('add-field')}`,
|
|
1708
|
+
}],
|
|
1709
|
+
isError: true,
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
if (args.fieldName && !args.fieldType && !enumTypeArg) {
|
|
1686
1713
|
const mappedOnly = args.dataField || args.dataSource;
|
|
1687
1714
|
return {
|
|
1688
1715
|
content: [{
|
|
@@ -1690,14 +1717,53 @@ export async function modifyD365FileTool(request, context) {
|
|
|
1690
1717
|
text: (mappedOnly
|
|
1691
1718
|
? `❌ dataField/dataSource describe a data-entity mapped field and do not apply to ` +
|
|
1692
1719
|
`objectType="${objectType}" — nothing was written.\n` +
|
|
1693
|
-
`On a table or table-extension a field needs fieldType (its EDT)
|
|
1694
|
-
|
|
1695
|
-
|
|
1720
|
+
`On a table or table-extension a field needs fieldType (its EDT), or ` +
|
|
1721
|
+
`fieldEnumType for an enum field.\n`
|
|
1722
|
+
: `❌ add-field on objectType="${objectType}" requires fieldType (the EDT), or ` +
|
|
1723
|
+
`fieldEnumType for an enum field — nothing was written.\n`) +
|
|
1696
1724
|
`\n${renderOpSpec('add-field')}`,
|
|
1697
1725
|
}],
|
|
1698
1726
|
isError: true,
|
|
1699
1727
|
};
|
|
1700
1728
|
}
|
|
1729
|
+
// Enum field: AxTableFieldEnum + <EnumType>, and NO EDT — an enum-typed table
|
|
1730
|
+
// field does not need one. Requiring an EDT here is what used to send callers off
|
|
1731
|
+
// building an AxEdtEnum wrapper, guessing at <Extends>, and failing the build twice
|
|
1732
|
+
// before getting there. fieldType stays accepted for the rarer "enum EDT" case.
|
|
1733
|
+
if (args.fieldName && enumTypeArg) {
|
|
1734
|
+
bridgeResult = await bridgeAddField(context.bridge, objectName, args.fieldName, 'Enum', args.fieldType, // usually undefined; an enum EDT when the caller has one
|
|
1735
|
+
args.fieldMandatory, args.fieldLabel);
|
|
1736
|
+
// EnumType is set in a second call on purpose: the bridge's AddField RPC has no
|
|
1737
|
+
// enumType parameter, while ModifyField does. Doing it here keeps this a
|
|
1738
|
+
// single tool call for the caller AND works with the bridge already deployed —
|
|
1739
|
+
// no rebuild, which is the part that silently keeps the old binary.
|
|
1740
|
+
if (bridgeResult?.success) {
|
|
1741
|
+
const enumSet = await bridgeModifyField(context.bridge, objectName, args.fieldName, { enumType: enumTypeArg });
|
|
1742
|
+
if (enumSet && !enumSet.success) {
|
|
1743
|
+
// Undo the half-written field. Two calls are not atomic, and what
|
|
1744
|
+
// they can leave behind — an AxTableFieldEnum with no enum — is a
|
|
1745
|
+
// field the caller did not ask for. Worse, the bridge's AddField
|
|
1746
|
+
// does not check for an existing field, so an agent that reads
|
|
1747
|
+
// "failed" and simply repeats the call ends up with the field
|
|
1748
|
+
// twice. Rolling back restores the pre-call state, which is the
|
|
1749
|
+
// only state a failed operation may leave.
|
|
1750
|
+
const undone = await bridgeRemoveField(context.bridge, objectName, args.fieldName);
|
|
1751
|
+
bridgeResult = {
|
|
1752
|
+
success: false,
|
|
1753
|
+
message: undone?.success
|
|
1754
|
+
? `EnumType could not be set (${enumSet.message}) — the field was rolled back and ` +
|
|
1755
|
+
`nothing was written. Check that enum "${enumTypeArg}" exists ` +
|
|
1756
|
+
`(get_object_info objectType="enum"), then retry add-field.`
|
|
1757
|
+
: `Field '${args.fieldName}' was created but EnumType could not be set ` +
|
|
1758
|
+
`(${enumSet.message}), and rolling the field back failed too. The field is an ` +
|
|
1759
|
+
`AxTableFieldEnum with no enum — do NOT repeat add-field, it would add a SECOND ` +
|
|
1760
|
+
`field of the same name. Fix it with operation="modify-field", ` +
|
|
1761
|
+
`fieldEnumType="${enumTypeArg}", or remove it with operation="remove-field".`,
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1701
1767
|
if (args.fieldName && args.fieldType) {
|
|
1702
1768
|
// fieldType is the EDT name; fieldBaseType is the primitive base type.
|
|
1703
1769
|
// When fieldBaseType is omitted, auto-resolve it from the symbol index so the
|
|
@@ -2190,13 +2256,29 @@ export async function modifyD365FileTool(request, context) {
|
|
|
2190
2256
|
// up front instead of letting it become a build error the agent hunts by hand.
|
|
2191
2257
|
const xppLintWarnings = lintXppSelect(args.sourceCode ?? args.methodCode ?? args.newCode);
|
|
2192
2258
|
const xppLintNote = xppLintWarnings.length > 0 ? `\n\n${xppLintWarnings.join('\n\n')}` : '';
|
|
2259
|
+
// Two BP rules fire on a field that compiles perfectly, so they are invisible
|
|
2260
|
+
// until a BP run several steps later — and one of them (the label copy) then
|
|
2261
|
+
// needs new labels, i.e. rework of what was just written. Say it here instead.
|
|
2262
|
+
let addFieldBpNote = '';
|
|
2263
|
+
if (operation === 'add-field' && (objectType === 'table' || objectType === 'table-extension')) {
|
|
2264
|
+
const notes = [
|
|
2265
|
+
`⚠️ BP: a table field must belong to a field group (BPErrorTableFieldNotInFieldGroup):\n` +
|
|
2266
|
+
` d365fo_file(action="modify", objectType="${objectType}", objectName="${objectName}", ` +
|
|
2267
|
+
`operation="add-field-to-field-group", fieldName="${args.fieldName}", fieldGroupName="<group>")`,
|
|
2268
|
+
];
|
|
2269
|
+
if (args.fieldEnumType) {
|
|
2270
|
+
notes.push(`⚠️ BP: the field's Label must be a DIFFERENT label id than the enum's own label ` +
|
|
2271
|
+
`(BPErrorFieldLabelIsCopyOfEnumLabel) — same visible text is fine, same id is not.`);
|
|
2272
|
+
}
|
|
2273
|
+
addFieldBpNote = `\n\n${notes.join('\n')}`;
|
|
2274
|
+
}
|
|
2193
2275
|
return {
|
|
2194
2276
|
content: [
|
|
2195
2277
|
{
|
|
2196
2278
|
type: 'text',
|
|
2197
2279
|
text: `✅ ${operation} on ${objectType} "${objectName}" — applied via IMetadataProvider.Update()\n\n` +
|
|
2198
2280
|
`**File:** ${actualFilePath}${addControlNote}${generationNote}${bridgeValidation}${projectMessage}\n` +
|
|
2199
|
-
`🔧 API: ${bridgeResult.message}${xppLintNote}${backupNote}` +
|
|
2281
|
+
`🔧 API: ${bridgeResult.message}${xppLintNote}${addFieldBpNote}${backupNote}` +
|
|
2200
2282
|
(ignoredParamsWarning ? `\n\n${ignoredParamsWarning}` : '') + `\n\n` +
|
|
2201
2283
|
`**Next steps:**\n- Review changes in Visual Studio\n- Build the model to validate`,
|
|
2202
2284
|
},
|
|
@@ -2279,7 +2361,7 @@ function findExtensionsInModel(symbolIndex, baseObject, model) {
|
|
|
2279
2361
|
*
|
|
2280
2362
|
* Idempotent: a name that already carries the prefix — in either the underscore
|
|
2281
2363
|
* or the bare form, case-insensitively — is left untouched, so an agent that
|
|
2282
|
-
* prefixes by hand does not end up with
|
|
2364
|
+
* prefixes by hand does not end up with DEMO_DEMO_Foo.
|
|
2283
2365
|
*/
|
|
2284
2366
|
export function applyExtensionMemberPrefix(args, objectType, operation, modelName) {
|
|
2285
2367
|
if (!EXTENSION_OBJECT_TYPES.has(objectType))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Prefix Configuration" section of `get_workspace_info`.
|
|
3
|
+
*
|
|
4
|
+
* Two rules decide everything here:
|
|
5
|
+
*
|
|
6
|
+
* 1. The prefix is reported for the model that WRITES land in — the write anchor
|
|
7
|
+
* (see ConfigManager.getWriteAnchorModel). A tool-initiated project switch
|
|
8
|
+
* moves the active project without moving that anchor, so after one the two
|
|
9
|
+
* are different models with different prefixes; reporting the active model's
|
|
10
|
+
* prefix would state a token no write would ever apply.
|
|
11
|
+
* 2. The reported value always carries its origin. The prefix can come from the
|
|
12
|
+
* model's own objects, from EXTENSION_PREFIX, or from the model name, and a
|
|
13
|
+
* bare "Effective prefix: ConFin" under "EXTENSION_PREFIX: Con" reads as
|
|
14
|
+
* approved rather than as the disagreement it is.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The model a write would actually land in.
|
|
18
|
+
*
|
|
19
|
+
* Normally the anchor: a project switch does not move writes. But when the
|
|
20
|
+
* operator has allowed writes into the switched-to model in configuration, the
|
|
21
|
+
* guard lets them through and they land in the ACTIVE model — so that is the
|
|
22
|
+
* model whose prefix a create would apply. Reporting the anchor's prefix there
|
|
23
|
+
* would be the same defect one state over.
|
|
24
|
+
*/
|
|
25
|
+
export declare function modelWritesLandIn(anchorModel: string | null, activeModel: string | null): string | null;
|
|
26
|
+
export interface PrefixDiagnostics {
|
|
27
|
+
/** Lines for the "## Prefix Configuration" section, blank line included. */
|
|
28
|
+
lines: string[];
|
|
29
|
+
/** The prefix a write would apply — for the Extension Naming samples. */
|
|
30
|
+
effectivePrefix: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* @param writeModel model writes are anchored to; the prefix is resolved for it
|
|
34
|
+
* @param readModel model reads currently come from — differs from `writeModel`
|
|
35
|
+
* only while a tool project switch is in effect
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildPrefixDiagnostics(writeModel: string | null, readModel: string | null): PrefixDiagnostics;
|
|
38
|
+
//# sourceMappingURL=prefixDiagnostics.d.ts.map
|