d365fo-mcp 1.8.2 → 1.8.3
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/tools/modifyD365File.js +54 -1
- package/dist/tools/validateObjectNaming.js +32 -32
- package/dist/utils/crossModelWriteGuard.d.ts +68 -0
- package/dist/utils/crossModelWriteGuard.js +122 -0
- package/dist/utils/pathContainment.d.ts +9 -0
- package/dist/utils/pathContainment.js +8 -2
- package/package.json +1 -1
|
@@ -28,6 +28,7 @@ import { validateEdtExtensionChange } from '../utils/edtExtensionValidator.js';
|
|
|
28
28
|
import { lintXppSelect } from '../utils/xppSelectLint.js';
|
|
29
29
|
import { getRequiredParams, renderOpSpec, OP_PARAM_ALIASES, findIgnoredParams, renderIgnoredParamsWarning, findMissingMutationParams, } from './d365foFileOpSpecs.js';
|
|
30
30
|
import { lookupSymbolNocase } from '../utils/symbolLookup.js';
|
|
31
|
+
import { crossModelWriteRefusal, baseObjectOf, } from '../utils/crossModelWriteGuard.js';
|
|
31
32
|
/**
|
|
32
33
|
* Decode the standard XML entities (<, >, ', ", &) and normalise
|
|
33
34
|
* line endings by stripping xml2js's 
 representation of carriage return.
|
|
@@ -1449,6 +1450,26 @@ export async function modifyD365FileTool(request, context) {
|
|
|
1449
1450
|
` • Form: d365fo_file(action="create", objectType="form-extension", objectName="${objectName}.${configuredModel || 'YourModel'}Extension")`);
|
|
1450
1451
|
}
|
|
1451
1452
|
}
|
|
1453
|
+
// 1c. Cross-model guard: the object is custom, but owned by a DIFFERENT custom
|
|
1454
|
+
// model than the one this workspace targets (shared "Core" model vs. the country
|
|
1455
|
+
// model that extends it). Editing it in place silently changes code the active
|
|
1456
|
+
// model only consumes — the wanted change is an extension in the active model.
|
|
1457
|
+
// Ownership comes from the path's <Model> segment, not the <Package> segment
|
|
1458
|
+
// used above: one package can carry several models.
|
|
1459
|
+
const owningModel = containment.modelSegment ?? null;
|
|
1460
|
+
const activeModel = getConfigManager().getModelName() ?? '';
|
|
1461
|
+
const crossModelRefusal = crossModelWriteRefusal({
|
|
1462
|
+
objectName,
|
|
1463
|
+
objectType,
|
|
1464
|
+
owningModel,
|
|
1465
|
+
owningPackage: containment.packageSegment ?? resolvedModelFromPath,
|
|
1466
|
+
activeModel,
|
|
1467
|
+
explicitModelName: modelName,
|
|
1468
|
+
existingExtensions: findExtensionsInModel(symbolIndex, baseObjectOf(objectName, objectType), activeModel),
|
|
1469
|
+
});
|
|
1470
|
+
if (crossModelRefusal) {
|
|
1471
|
+
throw new Error(crossModelRefusal);
|
|
1472
|
+
}
|
|
1452
1473
|
// 2. Resolve actual XML file path (DB may store JSON metadata with sourcePath)
|
|
1453
1474
|
let actualFilePath = filePath;
|
|
1454
1475
|
try {
|
|
@@ -1516,7 +1537,11 @@ export async function modifyD365FileTool(request, context) {
|
|
|
1516
1537
|
// spells this out ("Fields in extensions → {Prefix}{FieldName}") and BP
|
|
1517
1538
|
// rejects the unprefixed form. This is applied once, here, so every writer
|
|
1518
1539
|
// below (bridge op and direct-XML fallback alike) sees the final name.
|
|
1519
|
-
const memberPrefixNote = applyExtensionMemberPrefix(args, objectType, operation,
|
|
1540
|
+
const memberPrefixNote = applyExtensionMemberPrefix(args, objectType, operation,
|
|
1541
|
+
// The MODEL segment, not the package: members added to an extension must
|
|
1542
|
+
// carry the prefix of the model that owns the extension file (ContosoFinanceSK
|
|
1543
|
+
// → ContosoSK_), and a package can hold a model whose prefix differs.
|
|
1544
|
+
containment.modelSegment || resolvedModelFromPath || modelName || getConfigManager().getModelName() || '');
|
|
1520
1545
|
if (memberPrefixNote)
|
|
1521
1546
|
generationNote += memberPrefixNote;
|
|
1522
1547
|
let bridgeResult = null;
|
|
@@ -2218,6 +2243,34 @@ const EXTENSION_MEMBER_NAME_ARG = {
|
|
|
2218
2243
|
'add-field-group': 'fieldGroupName',
|
|
2219
2244
|
'add-enum-value': 'enumValueName',
|
|
2220
2245
|
};
|
|
2246
|
+
/**
|
|
2247
|
+
* Extensions of `baseObject` that already live in `model` — so the cross-model
|
|
2248
|
+
* refusal can point at the extension the active model ALREADY has instead of
|
|
2249
|
+
* telling the agent to create a second one next to it.
|
|
2250
|
+
*
|
|
2251
|
+
* Reads `extension_metadata`, which is indexed on base_object_name and small
|
|
2252
|
+
* enough that COLLATE NOCASE here costs nothing (unlike the 1M-row symbols
|
|
2253
|
+
* table). Best-effort: a missing table or unbuilt index yields no suggestions,
|
|
2254
|
+
* never an error — the refusal itself does not depend on it.
|
|
2255
|
+
*/
|
|
2256
|
+
function findExtensionsInModel(symbolIndex, baseObject, model) {
|
|
2257
|
+
if (!baseObject || !model)
|
|
2258
|
+
return [];
|
|
2259
|
+
try {
|
|
2260
|
+
const rdb = symbolIndex?.getReadDb?.();
|
|
2261
|
+
if (!rdb)
|
|
2262
|
+
return [];
|
|
2263
|
+
const rows = rdb.prepare(`SELECT extension_name AS name, extension_type AS type
|
|
2264
|
+
FROM extension_metadata
|
|
2265
|
+
WHERE base_object_name = ? COLLATE NOCASE
|
|
2266
|
+
AND model = ? COLLATE NOCASE
|
|
2267
|
+
LIMIT 5`).all(baseObject, model);
|
|
2268
|
+
return rows ?? [];
|
|
2269
|
+
}
|
|
2270
|
+
catch {
|
|
2271
|
+
return [];
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2221
2274
|
/**
|
|
2222
2275
|
* Prefix the new member name this operation carries, when writing into an
|
|
2223
2276
|
* extension. Mutates `args` in place and returns a note for the response (empty
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* detect conflicts against the symbol index, and suggest correct names.
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { getObjectSuffix, getExtensionNamingStyle } from '../utils/modelClassifier.js';
|
|
7
|
+
import { getObjectSuffix, getExtensionNamingStyle, resolveObjectPrefix, deriveExtensionInfix, } from '../utils/modelClassifier.js';
|
|
8
8
|
import { getConfigManager } from '../utils/configManager.js';
|
|
9
9
|
import { lookupSymbolNocase, lookupSymbolsNocase } from '../utils/symbolLookup.js';
|
|
10
10
|
const ValidateObjectNamingArgsSchema = z.object({
|
|
@@ -46,26 +46,25 @@ export async function validateObjectNamingTool(request, context) {
|
|
|
46
46
|
else if (name.length > 70) {
|
|
47
47
|
warnings.push(`Name is ${name.length} characters — approaching the ${MAX_NAME_LENGTH}-char AOT limit. Consider a shorter name to leave room for extensions.`);
|
|
48
48
|
}
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (envPrefix) {
|
|
54
|
-
prefix = envPrefix.toUpperCase();
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
prefix = detectModelPrefix(db, name);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
// Under EXTENSION_NAMING_STYLE=model-name the extension token is the model name (Visual Studio
|
|
61
|
-
// default) rather than the prefix infix: class extension → {Base}_{ModelName}_Extension,
|
|
62
|
-
// element extension → {Base}.{ModelName}. Explicit modelName arg wins over workspace config.
|
|
49
|
+
// The model whose convention is being validated against: explicit arg, else the
|
|
50
|
+
// model this workspace targets. It decides the prefix, so it is resolved BEFORE
|
|
51
|
+
// the prefix — a workspace on ContosoFinanceSK must validate against ContosoSK even
|
|
52
|
+
// when EXTENSION_PREFIX still says something else.
|
|
63
53
|
const namingStyle = getExtensionNamingStyle();
|
|
64
|
-
|
|
65
|
-
if (!modelName && namingStyle === 'model-name') {
|
|
66
|
-
modelName = getConfigManager().getModelName() ?? '';
|
|
67
|
-
}
|
|
54
|
+
const modelName = args.modelName?.trim() || getConfigManager().getModelName() || '';
|
|
68
55
|
const useModelName = namingStyle === 'model-name' && !!modelName;
|
|
56
|
+
// Resolve model prefix: explicit arg → the active model's own prefix
|
|
57
|
+
// (resolveObjectPrefix: learned from that model's objects, else EXTENSION_PREFIX,
|
|
58
|
+
// else the model name) → DB auto-detect for an unconfigured workspace.
|
|
59
|
+
// NOT upper-cased: "ContosoFin" is a PascalCase prefix, and "CONTOSOFIN" would make every
|
|
60
|
+
// suggested name and every startsWith() check below wrong.
|
|
61
|
+
let prefix = args.modelPrefix?.trim() || '';
|
|
62
|
+
if (!prefix) {
|
|
63
|
+
prefix = resolveObjectPrefix(modelName) || detectModelPrefix(db, name);
|
|
64
|
+
}
|
|
65
|
+
// Token embedded in extension element/class names — the model's own infix when its
|
|
66
|
+
// existing extensions state one (ContosoFinanceSK → "ContosoSK"), else derived.
|
|
67
|
+
const extensionInfix = prefix ? deriveExtensionInfix(prefix, modelName) : '';
|
|
69
68
|
// Rule set 1: extension naming rules
|
|
70
69
|
if (isExtension) {
|
|
71
70
|
const baseObjectName = args.baseObjectName;
|
|
@@ -77,8 +76,8 @@ export async function validateObjectNamingTool(request, context) {
|
|
|
77
76
|
// prefix style → {Base}{Prefix}_Extension; model-name style → {Base}_{ModelName}_Extension
|
|
78
77
|
const expectedPattern = useModelName
|
|
79
78
|
? `${baseObjectName}_${modelName}_Extension`
|
|
80
|
-
: `${baseObjectName}${
|
|
81
|
-
const expectedToken = useModelName ? modelName :
|
|
79
|
+
: `${baseObjectName}${extensionInfix}_Extension`;
|
|
80
|
+
const expectedToken = useModelName ? modelName : extensionInfix;
|
|
82
81
|
if (!name.startsWith(baseObjectName)) {
|
|
83
82
|
errors.push(`Class extension names must start with the base class name.\n Expected format: ${expectedPattern}`);
|
|
84
83
|
if (expectedToken)
|
|
@@ -98,12 +97,12 @@ export async function validateObjectNamingTool(request, context) {
|
|
|
98
97
|
!middle.toLowerCase().includes(expectedToken.toLowerCase())) {
|
|
99
98
|
warnings.push(useModelName
|
|
100
99
|
? `Extension name does not embed the model name "${modelName}" (EXTENSION_NAMING_STYLE=model-name).\n Current: ${name}\n Recommended: ${expectedPattern}`
|
|
101
|
-
: `Extension name does not include model
|
|
100
|
+
: `Extension name does not include model "${modelName || '(unknown)'}"'s extension infix "${extensionInfix}".\n Current: ${name}\n Recommended: ${expectedPattern}`);
|
|
102
101
|
}
|
|
103
102
|
}
|
|
104
103
|
suggestions.push(useModelName
|
|
105
104
|
? `AOT name for an element extension instead: ${baseObjectName}.${modelName}`
|
|
106
|
-
: `AOT label for extension file: ${baseObjectName}.${
|
|
105
|
+
: `AOT label for extension file: ${baseObjectName}.${extensionInfix}Extension (if creating table-extension AOT object instead)`);
|
|
107
106
|
}
|
|
108
107
|
else if (useModelName) {
|
|
109
108
|
// AOT extensions (table/form/enum/edt), model-name style: {Base}.{ModelName} — bare model
|
|
@@ -124,8 +123,8 @@ export async function validateObjectNamingTool(request, context) {
|
|
|
124
123
|
}
|
|
125
124
|
}
|
|
126
125
|
else {
|
|
127
|
-
// AOT extensions (table/form/enum/edt), prefix style: {Base}.{
|
|
128
|
-
const expectedPattern = `${baseObjectName}.${
|
|
126
|
+
// AOT extensions (table/form/enum/edt), prefix style: {Base}.{Infix}Extension.
|
|
127
|
+
const expectedPattern = `${baseObjectName}.${extensionInfix}Extension`;
|
|
129
128
|
if (!name.includes('.')) {
|
|
130
129
|
errors.push(`${args.objectType} names must use dot notation: {Base}.{Prefix}Extension.\n Expected: ${expectedPattern}`);
|
|
131
130
|
if (prefix)
|
|
@@ -137,10 +136,10 @@ export async function validateObjectNamingTool(request, context) {
|
|
|
137
136
|
errors.push(`Extension base (before '.') must exactly match baseObjectName.\n Expected: ${baseObjectName}.xxx\n Got: ${basePart}.xxx`);
|
|
138
137
|
}
|
|
139
138
|
if (!extPart.endsWith('Extension')) {
|
|
140
|
-
errors.push(`Extension suffix (after '.') must end with 'Extension'.\n Expected: ${
|
|
139
|
+
errors.push(`Extension suffix (after '.') must end with 'Extension'.\n Expected: ${extensionInfix}Extension\n Got: ${extPart}`);
|
|
141
140
|
}
|
|
142
|
-
else if (
|
|
143
|
-
warnings.push(`Extension suffix should start with model
|
|
141
|
+
else if (extensionInfix && !extPart.toLowerCase().startsWith(extensionInfix.toLowerCase())) {
|
|
142
|
+
warnings.push(`Extension suffix should start with model "${modelName || '(unknown)'}"'s infix "${extensionInfix}".\n Current: ${extPart}\n Recommended: ${extensionInfix}Extension`);
|
|
144
143
|
}
|
|
145
144
|
}
|
|
146
145
|
}
|
|
@@ -173,7 +172,7 @@ export async function validateObjectNamingTool(request, context) {
|
|
|
173
172
|
}
|
|
174
173
|
}
|
|
175
174
|
if (prefix) {
|
|
176
|
-
if (!name.
|
|
175
|
+
if (!name.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
177
176
|
warnings.push(`Proposed name does not start with model prefix "${prefix}". All custom objects should be prefixed to avoid conflicts.`);
|
|
178
177
|
suggestions.push(`Prefixed name: ${prefix}${name}`);
|
|
179
178
|
}
|
|
@@ -220,12 +219,13 @@ export async function validateObjectNamingTool(request, context) {
|
|
|
220
219
|
let output = `Validation: "${name}" as ${args.objectType}\n`;
|
|
221
220
|
if (args.baseObjectName)
|
|
222
221
|
output += `Base Object: ${args.baseObjectName}\n`;
|
|
223
|
-
if (prefix)
|
|
224
|
-
output += `Model Prefix: ${prefix}\n`;
|
|
222
|
+
if (prefix) {
|
|
223
|
+
output += `Model Prefix: ${prefix}${modelName ? ` (model ${modelName})` : ''}\n`;
|
|
224
|
+
}
|
|
225
225
|
if (isExtension) {
|
|
226
226
|
output += useModelName
|
|
227
227
|
? `Extension Style: model-name (token = model name "${modelName}")\n`
|
|
228
|
-
: `Extension Style: prefix (token =
|
|
228
|
+
: `Extension Style: prefix (token = "${extensionInfix}")\n`;
|
|
229
229
|
if (namingStyle === 'model-name' && !modelName) {
|
|
230
230
|
output += ` ⚠ EXTENSION_NAMING_STYLE=model-name but no model name could be resolved — validated structure only. Pass modelName to validate the extension token.\n`;
|
|
231
231
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-model write guard.
|
|
3
|
+
*
|
|
4
|
+
* `modify` already refuses to touch objects in standard Microsoft models
|
|
5
|
+
* (isStandardModel), but that check says nothing about the far more common
|
|
6
|
+
* real-world layout: a customer solution split across several CUSTOM models,
|
|
7
|
+
* e.g. a shared `ContosoFinanceCore` plus country models `ContosoFinanceSK` /
|
|
8
|
+
* `ContosoFinanceCZ` that extend it. The workspace's .rnrproj names exactly ONE of
|
|
9
|
+
* them as the target model; every other model is somebody else's code as far as
|
|
10
|
+
* this workspace is concerned.
|
|
11
|
+
*
|
|
12
|
+
* Without this guard the failure is silent and expensive: asked to "add a field
|
|
13
|
+
* to <table>", the agent resolves the table by name, lands in the shared model
|
|
14
|
+
* that happens to own it, and edits it in place. The field is then invisible in
|
|
15
|
+
* the active model's project and version control, and it lands in code every
|
|
16
|
+
* other country model inherits — instead of the one thing that was wanted, a
|
|
17
|
+
* table extension in the active model.
|
|
18
|
+
*
|
|
19
|
+
* So a write whose resolved file lives in a model other than the active one is
|
|
20
|
+
* refused by default, with the extension route spelled out. Two deliberate
|
|
21
|
+
* escape hatches, mirroring the standard-model guard's "explicit modelName =
|
|
22
|
+
* you know what you're doing":
|
|
23
|
+
* - `modelName="<owning model>"` on the call — a per-call, per-model opt-in,
|
|
24
|
+
* - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — an environment-wide opt-out for
|
|
25
|
+
* setups where one server really does serve several models.
|
|
26
|
+
*/
|
|
27
|
+
/** An extension of the target object that already exists in the active model. */
|
|
28
|
+
export interface ExistingExtension {
|
|
29
|
+
name: string;
|
|
30
|
+
type: string;
|
|
31
|
+
}
|
|
32
|
+
export interface CrossModelWriteCheck {
|
|
33
|
+
/** Object being written, as resolved (may already be an extension). */
|
|
34
|
+
objectName: string;
|
|
35
|
+
/** d365fo_file objectType, e.g. 'table', 'table-extension', 'class'. */
|
|
36
|
+
objectType: string;
|
|
37
|
+
/** Model that owns the resolved file (the `<Model>` path segment). */
|
|
38
|
+
owningModel: string | null | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* `<Package>` segment of the same path. A match on EITHER segment counts as
|
|
41
|
+
* "same model": most custom models sit in a package of the same name, but a
|
|
42
|
+
* configured model name occasionally matches only the package (several models
|
|
43
|
+
* in one package, or a model folder named after the package). Accepting both
|
|
44
|
+
* keeps the guard from firing on the workspace's own objects.
|
|
45
|
+
*/
|
|
46
|
+
owningPackage?: string | null;
|
|
47
|
+
/** Model the workspace targets (.rnrproj / D365FO_MODEL_NAME). */
|
|
48
|
+
activeModel: string | null | undefined;
|
|
49
|
+
/** `modelName` as passed by the caller — the per-call opt-in. */
|
|
50
|
+
explicitModelName?: string | null;
|
|
51
|
+
/** Extensions of the base object that already exist in the active model. */
|
|
52
|
+
existingExtensions?: ExistingExtension[];
|
|
53
|
+
}
|
|
54
|
+
/** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */
|
|
55
|
+
export declare function baseObjectOf(objectName: string, objectType: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* The name the extension WOULD get in `activeModel`, following that model's own
|
|
58
|
+
* naming (prefix inference + EXTENSION_NAMING_STYLE), or null when the type has
|
|
59
|
+
* no extension form. Class extensions use the `{Base}{Infix}_Extension` shape;
|
|
60
|
+
* everything else the dot-notation element form.
|
|
61
|
+
*/
|
|
62
|
+
export declare function suggestedExtensionName(baseObject: string, baseType: string, activeModel: string): string | null;
|
|
63
|
+
/**
|
|
64
|
+
* Refusal message for a write into a model other than the active one, or null
|
|
65
|
+
* when the write is allowed.
|
|
66
|
+
*/
|
|
67
|
+
export declare function crossModelWriteRefusal(check: CrossModelWriteCheck): string | null;
|
|
68
|
+
//# sourceMappingURL=crossModelWriteGuard.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-model write guard.
|
|
3
|
+
*
|
|
4
|
+
* `modify` already refuses to touch objects in standard Microsoft models
|
|
5
|
+
* (isStandardModel), but that check says nothing about the far more common
|
|
6
|
+
* real-world layout: a customer solution split across several CUSTOM models,
|
|
7
|
+
* e.g. a shared `ContosoFinanceCore` plus country models `ContosoFinanceSK` /
|
|
8
|
+
* `ContosoFinanceCZ` that extend it. The workspace's .rnrproj names exactly ONE of
|
|
9
|
+
* them as the target model; every other model is somebody else's code as far as
|
|
10
|
+
* this workspace is concerned.
|
|
11
|
+
*
|
|
12
|
+
* Without this guard the failure is silent and expensive: asked to "add a field
|
|
13
|
+
* to <table>", the agent resolves the table by name, lands in the shared model
|
|
14
|
+
* that happens to own it, and edits it in place. The field is then invisible in
|
|
15
|
+
* the active model's project and version control, and it lands in code every
|
|
16
|
+
* other country model inherits — instead of the one thing that was wanted, a
|
|
17
|
+
* table extension in the active model.
|
|
18
|
+
*
|
|
19
|
+
* So a write whose resolved file lives in a model other than the active one is
|
|
20
|
+
* refused by default, with the extension route spelled out. Two deliberate
|
|
21
|
+
* escape hatches, mirroring the standard-model guard's "explicit modelName =
|
|
22
|
+
* you know what you're doing":
|
|
23
|
+
* - `modelName="<owning model>"` on the call — a per-call, per-model opt-in,
|
|
24
|
+
* - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — an environment-wide opt-out for
|
|
25
|
+
* setups where one server really does serve several models.
|
|
26
|
+
*/
|
|
27
|
+
import { resolveObjectPrefix, applyObjectPrefix } from './modelClassifier.js';
|
|
28
|
+
/** Base object type → the d365fo_file objectType used to extend it. */
|
|
29
|
+
const EXTENSION_TYPE_OF = {
|
|
30
|
+
table: 'table-extension',
|
|
31
|
+
form: 'form-extension',
|
|
32
|
+
enum: 'enum-extension',
|
|
33
|
+
edt: 'edt-extension',
|
|
34
|
+
view: 'view-extension',
|
|
35
|
+
query: 'query-extension',
|
|
36
|
+
map: 'map-extension',
|
|
37
|
+
'data-entity': 'data-entity-extension',
|
|
38
|
+
menu: 'menu-extension',
|
|
39
|
+
class: 'class-extension',
|
|
40
|
+
};
|
|
41
|
+
function eq(a, b) {
|
|
42
|
+
return !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
/** True when the operator has opted out of the guard environment-wide. */
|
|
45
|
+
function guardDisabled() {
|
|
46
|
+
const v = process.env.D365FO_ALLOW_CROSS_MODEL_WRITE?.trim().toLowerCase();
|
|
47
|
+
return v === 'true' || v === '1' || v === 'yes';
|
|
48
|
+
}
|
|
49
|
+
/** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */
|
|
50
|
+
export function baseObjectOf(objectName, objectType) {
|
|
51
|
+
if (objectName.includes('.'))
|
|
52
|
+
return objectName.slice(0, objectName.indexOf('.'));
|
|
53
|
+
if (objectType === 'class-extension' && objectName.endsWith('_Extension')) {
|
|
54
|
+
return objectName.slice(0, -'_Extension'.length);
|
|
55
|
+
}
|
|
56
|
+
return objectName;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The name the extension WOULD get in `activeModel`, following that model's own
|
|
60
|
+
* naming (prefix inference + EXTENSION_NAMING_STYLE), or null when the type has
|
|
61
|
+
* no extension form. Class extensions use the `{Base}{Infix}_Extension` shape;
|
|
62
|
+
* everything else the dot-notation element form.
|
|
63
|
+
*/
|
|
64
|
+
export function suggestedExtensionName(baseObject, baseType, activeModel) {
|
|
65
|
+
if (!EXTENSION_TYPE_OF[baseType])
|
|
66
|
+
return null;
|
|
67
|
+
const prefix = resolveObjectPrefix(activeModel);
|
|
68
|
+
if (!prefix)
|
|
69
|
+
return null;
|
|
70
|
+
return baseType === 'class'
|
|
71
|
+
? applyObjectPrefix(`${baseObject}_Extension`, prefix, activeModel)
|
|
72
|
+
: applyObjectPrefix(`${baseObject}.Extension`, prefix, activeModel);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Refusal message for a write into a model other than the active one, or null
|
|
76
|
+
* when the write is allowed.
|
|
77
|
+
*/
|
|
78
|
+
export function crossModelWriteRefusal(check) {
|
|
79
|
+
const { objectName, objectType, owningModel, activeModel, explicitModelName } = check;
|
|
80
|
+
// Nothing to compare against — an unconfigured workspace or a path whose model
|
|
81
|
+
// segment could not be determined. Never block on a guess.
|
|
82
|
+
if (!owningModel || !activeModel)
|
|
83
|
+
return null;
|
|
84
|
+
if (eq(owningModel, activeModel) || eq(check.owningPackage, activeModel))
|
|
85
|
+
return null;
|
|
86
|
+
// Per-call opt-in: the caller named the owning model (or its package) outright.
|
|
87
|
+
if (eq(explicitModelName, owningModel) || eq(explicitModelName, check.owningPackage))
|
|
88
|
+
return null;
|
|
89
|
+
if (guardDisabled()) {
|
|
90
|
+
console.error(`[crossModelWriteGuard] D365FO_ALLOW_CROSS_MODEL_WRITE — allowing write to "${objectName}" ` +
|
|
91
|
+
`in model "${owningModel}" (active model "${activeModel}")`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const isExtension = objectType.endsWith('-extension');
|
|
95
|
+
const baseObject = baseObjectOf(objectName, objectType);
|
|
96
|
+
const baseType = isExtension ? objectType.slice(0, -'-extension'.length) : objectType;
|
|
97
|
+
const extType = EXTENSION_TYPE_OF[baseType];
|
|
98
|
+
const lines = [
|
|
99
|
+
`⛔ Refusing to modify "${objectName}" — it belongs to model "${owningModel}", ` +
|
|
100
|
+
`not to this workspace's model "${activeModel}".`,
|
|
101
|
+
'',
|
|
102
|
+
`"${owningModel}" is a different model: the change would land in code that "${activeModel}" ` +
|
|
103
|
+
`only consumes, it would not appear in this workspace's project or version control, and every ` +
|
|
104
|
+
`other model built on "${owningModel}" would inherit it.`,
|
|
105
|
+
'',
|
|
106
|
+
];
|
|
107
|
+
if (extType) {
|
|
108
|
+
const existing = (check.existingExtensions ?? []).filter(e => !eq(e.name, objectName));
|
|
109
|
+
lines.push(`Extend it from "${activeModel}" instead:`);
|
|
110
|
+
if (existing.length > 0) {
|
|
111
|
+
lines.push(` • "${activeModel}" already extends ${baseObject} — add to that extension:`, ...existing.slice(0, 5).map(e => ` d365fo_file(action="modify", objectType="${extType}", objectName="${e.name}", modelName="${activeModel}", operation=…)`));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const suggested = suggestedExtensionName(baseObject, baseType, activeModel);
|
|
115
|
+
lines.push(` • no extension of ${baseObject} exists in "${activeModel}" yet — create one:`, ` d365fo_file(action="create", objectType="${extType}", objectName="${suggested ?? `${baseObject}.<Prefix>Extension`}", modelName="${activeModel}")`, ` then add the member to it with action="modify".`);
|
|
116
|
+
}
|
|
117
|
+
lines.push('');
|
|
118
|
+
}
|
|
119
|
+
lines.push(`If editing "${owningModel}" in place is genuinely what you want, say so explicitly:`, ` • pass modelName="${owningModel}" on this call, or`, ` • switch the workspace to that model: get_workspace_info(projectName="${owningModel}"), or`, ` • set D365FO_ALLOW_CROSS_MODEL_WRITE=true to disable this guard for the whole server.`);
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=crossModelWriteGuard.js.map
|
|
@@ -24,6 +24,15 @@ export interface PathContainmentResult {
|
|
|
24
24
|
canonicalPath?: string;
|
|
25
25
|
/** Matched root (for diagnostics). */
|
|
26
26
|
matchedRoot?: string;
|
|
27
|
+
/** `<Package>` segment of the canonical layout, when ok. */
|
|
28
|
+
packageSegment?: string;
|
|
29
|
+
/**
|
|
30
|
+
* `<Model>` segment of the canonical layout, when ok — the model that actually
|
|
31
|
+
* OWNS the file. Package and model differ whenever one package carries several
|
|
32
|
+
* models, so ownership decisions (see crossModelWriteGuard) must read this and
|
|
33
|
+
* not the package name.
|
|
34
|
+
*/
|
|
35
|
+
modelSegment?: string;
|
|
27
36
|
}
|
|
28
37
|
/**
|
|
29
38
|
* Validate that `filePath` points at a D365FO AOT file inside an allowed root
|
|
@@ -195,7 +195,7 @@ export async function assertWritePathAllowed(filePath, modelHint, opts) {
|
|
|
195
195
|
reason: `Path does not match canonical AOT layout (<Package>/<Model>/Ax<Type>/<File>):\n ${filePath}`,
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
|
-
const [, modelSeg, axFolder, lastSeg] = parts;
|
|
198
|
+
const [packageSeg, modelSeg, axFolder, lastSeg] = parts;
|
|
199
199
|
if (!/^Ax[A-Z]/.test(axFolder)) {
|
|
200
200
|
return {
|
|
201
201
|
ok: false,
|
|
@@ -218,7 +218,13 @@ export async function assertWritePathAllowed(filePath, modelHint, opts) {
|
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
-
return {
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
canonicalPath: canonical,
|
|
224
|
+
matchedRoot,
|
|
225
|
+
packageSegment: packageSeg,
|
|
226
|
+
modelSegment: modelSeg,
|
|
227
|
+
};
|
|
222
228
|
}
|
|
223
229
|
/** Throwing wrapper — convenient in tool handlers. */
|
|
224
230
|
export async function ensureWritePathAllowed(filePath, modelHint, opts) {
|