d365fo-mcp 1.17.4 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.github/copilot-instructions.md +1 -0
  2. package/bridge/D365MetadataBridge/Services/MetadataWriteService.cs +13 -11
  3. package/dist/bridge/bridgeAdapter.d.ts +25 -0
  4. package/dist/bridge/bridgeAdapter.js +42 -8
  5. package/dist/bridge/bridgeTypes.d.ts +14 -0
  6. package/dist/cli/ui.d.ts +9 -2
  7. package/dist/cli/ui.js +8 -1
  8. package/dist/config/settings.js +18 -0
  9. package/dist/metadata/modelDescriptor.d.ts +139 -2
  10. package/dist/metadata/modelDescriptor.js +249 -3
  11. package/dist/scripts/build-database.js +15 -0
  12. package/dist/scripts/build-fts.js +15 -0
  13. package/dist/scripts/extract-metadata.js +15 -0
  14. package/dist/server/toolSchemas/d365foFile.js +2 -1
  15. package/dist/tools/analysis/findReferences.js +75 -25
  16. package/dist/tools/analysis/validateObjectNaming.js +15 -4
  17. package/dist/tools/d365foFile.js +27 -8
  18. package/dist/tools/readers/classInfo.js +58 -6
  19. package/dist/tools/readers/getMethodSource.d.ts +12 -1
  20. package/dist/tools/readers/getMethodSource.js +75 -20
  21. package/dist/tools/readers/getWorkspaceInfo.js +53 -6
  22. package/dist/tools/sdlc/buildProject.js +90 -0
  23. package/dist/tools/sdlc/compilerMetadataPrune.d.ts +50 -0
  24. package/dist/tools/sdlc/compilerMetadataPrune.js +129 -0
  25. package/dist/tools/sdlc/updateSymbolIndex.d.ts +1 -0
  26. package/dist/tools/sdlc/updateSymbolIndex.js +26 -0
  27. package/dist/tools/smart/codeGen.js +47 -50
  28. package/dist/tools/specs/d365foFileOpSpecs.js +37 -0
  29. package/dist/tools/write/directXmlWriters.d.ts +31 -0
  30. package/dist/tools/write/directXmlWriters.js +129 -0
  31. package/dist/tools/write/formDataSourceNote.d.ts +22 -0
  32. package/dist/tools/write/formDataSourceNote.js +64 -0
  33. package/dist/tools/write/inlineIndexUpsert.js +6 -0
  34. package/dist/tools/write/inlineWriteVerification.d.ts +1 -1
  35. package/dist/tools/write/inlineWriteVerification.js +10 -1
  36. package/dist/tools/write/modifyD365File.d.ts +11 -0
  37. package/dist/tools/write/modifyD365File.js +150 -15
  38. package/dist/tools/write/preserveMetadataElements.js +1 -0
  39. package/dist/utils/indexedMethodSource.d.ts +76 -0
  40. package/dist/utils/indexedMethodSource.js +108 -0
  41. package/dist/utils/methodBodyHint.d.ts +12 -0
  42. package/dist/utils/methodBodyHint.js +12 -0
  43. package/dist/utils/modelClassifier.d.ts +26 -0
  44. package/dist/utils/modelClassifier.js +52 -3
  45. package/dist/utils/objectFileLookup.d.ts +41 -0
  46. package/dist/utils/objectFileLookup.js +84 -0
  47. package/dist/utils/objectNaming.js +12 -5
  48. package/dist/utils/objectNamingRules.d.ts +2 -0
  49. package/dist/utils/objectNamingRules.js +73 -5
  50. package/dist/utils/pathContainment.d.ts +7 -1
  51. package/dist/utils/pathContainment.js +39 -1
  52. package/dist/utils/toolProgressMessage.js +4 -0
  53. package/dist/utils/xmlScan.js +7 -1
  54. package/dist/workspace/projectMembership.d.ts +15 -0
  55. package/dist/workspace/projectMembership.js +17 -0
  56. package/package.json +1 -1
@@ -74,6 +74,7 @@ PowerShell / any terminal command **WILL HANG** in VS 2022 / VS 2026 MCP integra
74
74
  - `model-name` → class `{Target}_{ModelName}_Extension`, element `{Target}.{ModelName}`
75
75
 
76
76
  Pass the BASE object name to `d365fo_file(action="create")` and let the tool inject the token — don't hand-build the infix.
77
+ These patterns describe how the tool names extensions, and the tool applies them itself — editing them here does not change the names it writes. Change the style with `d365fo-mcp config naming`.
77
78
 
78
79
  ### Reuse & diff safety
79
80
 
@@ -3483,14 +3483,11 @@ namespace D365MetadataBridge.Services
3483
3483
  ?? throw new ArgumentException($"Form extension '{objectName}' not found");
3484
3484
  var msi = GetModelSaveInfoForObject(_provider.FormExtensions, objectName);
3485
3485
 
3486
- // Same idempotency rule as the form branch below: skip on a name match,
3487
- // and on a different-named data source already bound to the same table.
3486
+ // Same idempotency rule as the form branch below: skip on a NAME match only.
3488
3487
  foreach (AxFormDataSourceRoot existing in axExt.DataSources)
3489
3488
  {
3490
3489
  if (string.Equals(existing.Name, dsName, StringComparison.OrdinalIgnoreCase))
3491
3490
  return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{dsName}' already exists", api = "IMetaFormExtensionProvider.Update" };
3492
- if (string.Equals(existing.Table, table, StringComparison.OrdinalIgnoreCase))
3493
- return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{existing.Name}' already binds table '{table}'", api = "IMetaFormExtensionProvider.Update" };
3494
3491
  }
3495
3492
 
3496
3493
  axExt.DataSources.Add((AxFormDataSourceRoot)CreateFormDataSourceRoot(dsName, table, joinSource, linkType));
@@ -3506,18 +3503,23 @@ namespace D365MetadataBridge.Services
3506
3503
  ?? throw new ArgumentException($"Form '{objectName}' not found");
3507
3504
  var msi = GetModelSaveInfoForObject(_provider.Forms, objectName);
3508
3505
 
3509
- // Idempotency: don't append a duplicate. If a data source with the same
3510
- // NAME already exists, skip (it may be a template stub already bound to the
3511
- // right table). If a DIFFERENT-named data source already binds the same
3512
- // TABLE, skip too — adding a second binding to the same table is almost
3513
- // always an accident (a stub the caller meant to replace, not duplicate).
3506
+ // Idempotency: don't append a duplicate. A data source with the same NAME
3507
+ // already there is a real conflict (it may be a template stub already bound
3508
+ // to the right table), so skip that one.
3509
+ //
3510
+ // A different-named data source already bound to the same TABLE is NOT a
3511
+ // conflict and must still be written. This used to skip too, on the theory
3512
+ // that "adding a second binding to the same table is almost always an
3513
+ // accident" — which is simply untrue: a form routinely joins one table into
3514
+ // several branches of its query. Microsoft's own PurchLineBackOrder carries
3515
+ // PurchTable and PurchTable1, both over PurchTable, with different link
3516
+ // types. The rule silently dropped three legitimate data sources and, because
3517
+ // `skipped` never reached the caller, reported all three as added.
3514
3518
  foreach (var existing in axForm.DataSources)
3515
3519
  {
3516
3520
  dynamic dyn = existing;
3517
3521
  if (string.Equals((string)dyn.Name, dsName, StringComparison.OrdinalIgnoreCase))
3518
3522
  return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{dsName}' already exists", api = "IMetaFormProvider.Update" };
3519
- if (string.Equals((string)dyn.Table, table, StringComparison.OrdinalIgnoreCase))
3520
- return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{(string)dyn.Name}' already binds table '{table}'", api = "IMetaFormProvider.Update" };
3521
3523
  }
3522
3524
 
3523
3525
  axForm.AddDataSource(CreateFormDataSourceRoot(dsName, table, joinSource, linkType));
@@ -234,6 +234,27 @@ export declare function bridgeResolveObject(bridge: BridgeClient | undefined, ob
234
234
  export declare function unappliedSuffix(result: {
235
235
  unsupportedProperties?: string[];
236
236
  }): string;
237
+ /** What a bridge write returns when it declined to do anything. */
238
+ export interface BridgeSkippable {
239
+ skipped?: boolean;
240
+ reason?: string;
241
+ }
242
+ /**
243
+ * Renders a bridge result that declined the write, or null when it did write.
244
+ *
245
+ * The bridge has long been able to answer `{ success: true, skipped: true, reason }`
246
+ * — it did the honest thing and said it changed nothing. Nothing on this side read
247
+ * `skipped`, so every decline fell through the caller's `result.success` branch and
248
+ * was rendered "✅ … added": a write that never happened, reported as one, with the
249
+ * bridge's own explanation discarded on the way past.
250
+ *
251
+ * `success` from the bridge means "this did not fail", NOT "the object changed".
252
+ * Any wrapper whose operation can skip must ask this before claiming a write.
253
+ *
254
+ * Exactly the lesson unappliedSuffix() above was added for, one field over — which
255
+ * is why both live here together.
256
+ */
257
+ export declare function skippedMessage(result: BridgeSkippable, subject: string): string | null;
237
258
  /**
238
259
  * Checks if bridge can handle this create operation.
239
260
  */
@@ -319,6 +340,7 @@ export declare function bridgeAddField(bridge: BridgeClient | undefined, tableNa
319
340
  }): Promise<{
320
341
  success: boolean;
321
342
  message: string;
343
+ skipped?: boolean;
322
344
  } | null>;
323
345
  /**
324
346
  * Sets a property via the C# bridge (IMetadataProvider.Update()).
@@ -425,6 +447,7 @@ export declare function bridgeRemoveFieldGroup(bridge: BridgeClient | undefined,
425
447
  export declare function bridgeAddFieldToFieldGroup(bridge: BridgeClient | undefined, tableName: string, groupName: string, fieldName: string, extendBaseFieldGroup?: boolean): Promise<{
426
448
  success: boolean;
427
449
  message: string;
450
+ skipped?: boolean;
428
451
  } | null>;
429
452
  /**
430
453
  * Modifies field properties on a table via the C# bridge.
@@ -488,6 +511,7 @@ export declare function bridgeAddControl(bridge: BridgeClient | undefined, formN
488
511
  export declare function bridgeAddDataSource(bridge: BridgeClient | undefined, objectType: string, objectName: string, dsName: string, table: string, joinSource?: string, linkType?: string): Promise<{
489
512
  success: boolean;
490
513
  message: string;
514
+ skipped?: boolean;
491
515
  } | null>;
492
516
  /**
493
517
  * Adds or updates a FieldModification entry in a table-extension via the C# bridge.
@@ -503,6 +527,7 @@ export declare function bridgeAddFieldModification(bridge: BridgeClient | undefi
503
527
  export declare function bridgeAddMenuItemToMenu(bridge: BridgeClient | undefined, menuName: string, menuItemToAdd: string, menuItemToAddType?: string): Promise<{
504
528
  success: boolean;
505
529
  message: string;
530
+ skipped?: boolean;
506
531
  } | null>;
507
532
  /**
508
533
  * Executes multiple write operations on a single object in one bridge call.
@@ -1254,6 +1254,9 @@ const BRIDGE_MODIFY_OPS = new Set([
1254
1254
  // no concept of it — so this is XML-only for the same structural reason as the
1255
1255
  // two above.
1256
1256
  'remove-diagnostic-suppression', 'add-diagnostic-suppression',
1257
+ // A model descriptor is not an AOT object either — it is the package's own
1258
+ // manifest, outside the AOT entirely — so the same XML-only reasoning applies.
1259
+ 'add-module-reference', 'remove-module-reference',
1257
1260
  'add-display-method', 'add-table-method',
1258
1261
  'add-field-modification', 'add-menu-item-to-menu',
1259
1262
  // No C# op exists for query ranges on entities — served entirely by a
@@ -1296,6 +1299,8 @@ const XML_ONLY_MODIFY_PAIRS = {
1296
1299
  'remove-entry-point': new Set(['security-privilege']),
1297
1300
  'remove-diagnostic-suppression': new Set(['ignore-diagnostic-list']),
1298
1301
  'add-diagnostic-suppression': new Set(['ignore-diagnostic-list']),
1302
+ 'add-module-reference': new Set(['model-descriptor']),
1303
+ 'remove-module-reference': new Set(['model-descriptor']),
1299
1304
  // An AxReport has no bridge write path at all — IMetadataProvider cannot express
1300
1305
  // a dataset field or a report parameter through a Dictionary<string,string> — and
1301
1306
  // `report` is deliberately absent from BRIDGE_MODIFY_TYPES for that reason. Without
@@ -1317,6 +1322,30 @@ export function unappliedSuffix(result) {
1317
1322
  return '';
1318
1323
  return `\n⚠️ NOT applied — this object type has nowhere to store them: ${dropped.join(', ')}`;
1319
1324
  }
1325
+ /**
1326
+ * Renders a bridge result that declined the write, or null when it did write.
1327
+ *
1328
+ * The bridge has long been able to answer `{ success: true, skipped: true, reason }`
1329
+ * — it did the honest thing and said it changed nothing. Nothing on this side read
1330
+ * `skipped`, so every decline fell through the caller's `result.success` branch and
1331
+ * was rendered "✅ … added": a write that never happened, reported as one, with the
1332
+ * bridge's own explanation discarded on the way past.
1333
+ *
1334
+ * `success` from the bridge means "this did not fail", NOT "the object changed".
1335
+ * Any wrapper whose operation can skip must ask this before claiming a write.
1336
+ *
1337
+ * Exactly the lesson unappliedSuffix() above was added for, one field over — which
1338
+ * is why both live here together.
1339
+ */
1340
+ export function skippedMessage(result, subject) {
1341
+ if (result.skipped !== true)
1342
+ return null;
1343
+ const reason = result.reason?.trim();
1344
+ return (`⏭️ NOT written — ${subject} was skipped by the bridge` +
1345
+ (reason ? `: ${reason}` : ' (no reason given)') +
1346
+ `\n Nothing changed on disk. If you meant to replace what is already there, ` +
1347
+ `remove or rename it first — re-sending this operation will skip again.`);
1348
+ }
1320
1349
  /**
1321
1350
  * Checks if bridge can handle this create operation.
1322
1351
  */
@@ -1480,9 +1509,10 @@ export async function bridgeAddField(bridge, tableName, fieldName, fieldType, ed
1480
1509
  const result = await bridge.addField(tableName, fieldName, fieldType, edt, mandatory, label, mapped?.dataField, mapped?.dataSource, mapped?.fieldGroupName);
1481
1510
  return {
1482
1511
  success: result.success,
1483
- message: result.success
1512
+ skipped: result.skipped === true,
1513
+ message: skippedMessage(result, `Field '${fieldName}'`) ?? (result.success
1484
1514
  ? `✅ Field '${fieldName}' added via ${result.api}`
1485
- : `Bridge addField returned success=false`,
1515
+ : `Bridge addField returned success=false`),
1486
1516
  };
1487
1517
  }
1488
1518
  catch (e) {
@@ -1839,9 +1869,10 @@ export async function bridgeAddFieldToFieldGroup(bridge, tableName, groupName, f
1839
1869
  : '<FieldGroups>';
1840
1870
  return {
1841
1871
  success: result.success,
1842
- message: result.success
1872
+ skipped: result.skipped === true,
1873
+ message: skippedMessage(result, `Field '${fieldName}' → group '${groupName}'`) ?? (result.success
1843
1874
  ? `✅ Field '${fieldName}' added to group '${groupName}' in ${target} via ${result.api}`
1844
- : `Bridge addFieldToFieldGroup returned success=false`,
1875
+ : `Bridge addFieldToFieldGroup returned success=false`),
1845
1876
  };
1846
1877
  }
1847
1878
  catch (e) {
@@ -2053,11 +2084,13 @@ export async function bridgeAddDataSource(bridge, objectType, objectName, dsName
2053
2084
  return null;
2054
2085
  try {
2055
2086
  const result = await bridge.addDataSource(objectType, objectName, dsName, table, joinSource, linkType);
2087
+ const skip = skippedMessage(result, `DataSource '${dsName}'`);
2056
2088
  return {
2057
2089
  success: result.success,
2058
- message: result.success
2090
+ skipped: result.skipped === true,
2091
+ message: skip ?? (result.success
2059
2092
  ? `✅ DataSource '${dsName}' added via ${result.api}`
2060
- : `Bridge addDataSource returned success=false`,
2093
+ : `Bridge addDataSource returned success=false`),
2061
2094
  };
2062
2095
  }
2063
2096
  catch (e) {
@@ -2106,9 +2139,10 @@ export async function bridgeAddMenuItemToMenu(bridge, menuName, menuItemToAdd, m
2106
2139
  const result = await bridge.addMenuItemToMenu(menuName, menuItemToAdd, menuItemToAddType);
2107
2140
  return {
2108
2141
  success: result.success,
2109
- message: result.success
2142
+ skipped: result.skipped === true,
2143
+ message: skippedMessage(result, `Menu item '${menuItemToAdd}'`) ?? (result.success
2110
2144
  ? `✅ Menu item '${menuItemToAdd}' added via ${result.api}`
2111
- : `Bridge addMenuItemToMenu returned success=false`,
2145
+ : `Bridge addMenuItemToMenu returned success=false`),
2112
2146
  };
2113
2147
  }
2114
2148
  catch (e) {
@@ -348,6 +348,20 @@ export interface BridgeWriteResult {
348
348
  * create ops and on add-control; the write itself still succeeded.
349
349
  */
350
350
  unsupportedProperties?: string[];
351
+ /**
352
+ * The bridge declined the operation and changed NOTHING — an element with that
353
+ * name is already there. `success` stays true because nothing failed, so a
354
+ * caller that reads only `success` reports a write that did not happen. Render
355
+ * it with skippedMessage() (bridgeAdapter.ts) rather than the success branch.
356
+ *
357
+ * Returned today by add-field (data-entity-extension), add-field-to-field-group,
358
+ * add-menu-item-to-menu and add-data-source. It was absent from this interface
359
+ * for as long as the bridge has been sending it, which is precisely why four
360
+ * wrappers could ignore it without so much as a type error.
361
+ */
362
+ skipped?: boolean;
363
+ /** Why the bridge skipped, in its own words. Only set alongside `skipped`. */
364
+ reason?: string;
351
365
  api?: string;
352
366
  }
353
367
  /** Result from createSmartTable — includes BP defaults summary */
package/dist/cli/ui.d.ts CHANGED
@@ -6,8 +6,15 @@
6
6
  import * as p from '@clack/prompts';
7
7
  import type { Option } from '@clack/prompts';
8
8
  export { p };
9
- /** Unwrap a clack result; exit gracefully when the user cancelled. */
10
- export declare function ensure<T>(value: T | symbol): T;
9
+ /**
10
+ * Unwrap a clack result; exit gracefully when the user cancelled.
11
+ *
12
+ * Typed as `Exclude<T, symbol>` rather than `(value: T | symbol): T`: since
13
+ * @clack/prompts 1.8.1 the prompts return `T | typeof CANCEL_SYMBOL`, a
14
+ * `unique symbol` that the `T | symbol` form no longer peeled off T, so every
15
+ * caller got the cancel symbol back in its type.
16
+ */
17
+ export declare function ensure<T>(value: T): Exclude<T, symbol>;
11
18
  export declare function askText(opts: {
12
19
  message: string;
13
20
  placeholder?: string;
package/dist/cli/ui.js CHANGED
@@ -6,7 +6,14 @@
6
6
  import * as p from '@clack/prompts';
7
7
  import { installOneLiner, isFullInstall } from './context.js';
8
8
  export { p };
9
- /** Unwrap a clack result; exit gracefully when the user cancelled. */
9
+ /**
10
+ * Unwrap a clack result; exit gracefully when the user cancelled.
11
+ *
12
+ * Typed as `Exclude<T, symbol>` rather than `(value: T | symbol): T`: since
13
+ * @clack/prompts 1.8.1 the prompts return `T | typeof CANCEL_SYMBOL`, a
14
+ * `unique symbol` that the `T | symbol` form no longer peeled off T, so every
15
+ * caller got the cancel symbol back in its type.
16
+ */
10
17
  export function ensure(value) {
11
18
  if (p.isCancel(value)) {
12
19
  p.cancel('Cancelled.');
@@ -273,6 +273,24 @@ export const SETTINGS = [
273
273
  { value: 'model-name', hint: 'CustTable.ContosoRobotics — embeds the model name (VS default)' },
274
274
  ],
275
275
  },
276
+ {
277
+ path: 'naming.extensionClassStyle',
278
+ env: 'EXTENSION_CLASS_NAMING_STYLE',
279
+ section: 'naming',
280
+ tier: 'advanced',
281
+ type: 'enum',
282
+ label: 'How extension CLASSES are named',
283
+ description: 'Overrides naming.extensionStyle for CoC extension classes only, leaving element extensions alone. ' +
284
+ 'Some conventions spell the two differently — Visual Studio elements (CustTable.ContosoRobotics) with ' +
285
+ 'prefix-style classes (CustTableCtso_Extension) — which a single style cannot express: it renames one ' +
286
+ 'or the other. Leave on inherit unless your convention names classes differently from elements.',
287
+ default: 'inherit',
288
+ choices: [
289
+ { value: 'inherit', hint: 'follow naming.extensionStyle — the default, behaviour unchanged' },
290
+ { value: 'prefix', hint: 'CustTableCtso_Extension — embeds the extension prefix' },
291
+ { value: 'model-name', hint: 'CustTable_ContosoRobotics_Extension — embeds the model name' },
292
+ ],
293
+ },
276
294
  // ── index ────────────────────────────────────────────────────────────────
277
295
  {
278
296
  path: 'index.extractMode',
@@ -16,8 +16,32 @@
16
16
  */
17
17
  /**
18
18
  * Extract every `<d2p1:string>` entry of a descriptor's `<ModuleReferences>`.
19
- * The sibling `<ModelReferences>` is `i:nil` in every descriptor observed on a
20
- * real box, so the flat scan cannot pick up entries from it.
19
+ *
20
+ * Scoped to that ELEMENT, not scanned flat over the document. The flat scan this
21
+ * replaces reasoned only about `<ModelReferences>` — `i:nil` in every descriptor
22
+ * observed on a real box, so it contributes nothing — and missed the OTHER
23
+ * `<d2p1:string>` arrays a descriptor carries: `<InternalsVisibleTo>` and
24
+ * `<AppliedUpdates>`. Both are ordinary string lists, and `<InternalsVisibleTo>`
25
+ * sorts BEFORE `<ModuleReferences>` in the serialized document, so its entries
26
+ * came back as module references.
27
+ *
28
+ * Measured on a 10.0.2527 PackagesLocalDirectory plus the custom roots beside
29
+ * it: 112 of 176 descriptors over-reported, 606 phantom references in total.
30
+ * `Foundation.xml` answered 157 for a model that references 46;
31
+ * `ApplicationPlatform.xml` answered 19 for a model that references NONE.
32
+ *
33
+ * That is not cosmetic. Both readers of this function treat the result as the
34
+ * set of packages a model may see: build_d365fo_project orders its build queue
35
+ * by it, and getModelVisibility turns it into `visiblePackages`, the oracle
36
+ * resolve_references uses to decide an indexed type is invisible to the model
37
+ * being compiled. Every phantom entry made that oracle MORE permissive, so the
38
+ * missing-reference defect it exists to catch was silently waved through —
39
+ * which is the exact compile-time `classStr`/type failure this module's own
40
+ * header describes.
41
+ *
42
+ * Returns [] for a nil or absent element: a model that references nothing. The
43
+ * "descriptor unreadable" case is null, and it is readModuleReferences's to
44
+ * report, not this one's.
21
45
  */
22
46
  export declare function parseModuleReferences(descriptorXml: string): string[];
23
47
  /**
@@ -46,6 +70,119 @@ export interface ModelVisibility {
46
70
  * descriptor turning into a wall of errors is worse than the gap it closes.
47
71
  */
48
72
  export declare function getModelVisibility(packagesRoot: string | null | undefined, modelName: string | null | undefined): ModelVisibility | null;
73
+ /**
74
+ * Drop the memoised visibility of `modelName` under every root.
75
+ *
76
+ * The descriptor writers call this after every successful write. Without it the
77
+ * oracle keeps the reference set the model had when it was first asked, so
78
+ * resolve_references goes on reporting `not-visible-from-model` — and telling
79
+ * the agent to add the very reference it has just added — until the server
80
+ * restarts; a remove leaves it too permissive the same way.
81
+ */
82
+ export declare function invalidateModelVisibility(modelName: string): void;
49
83
  /** Uncached form — exported for tests, which need a fresh fixture each time. */
50
84
  export declare function buildModelVisibility(packagesRoot: string | null | undefined, modelName: string | null | undefined): ModelVisibility | null;
85
+ export type AddModuleReferenceResult =
86
+ /** Added. `xml` is the updated document. */
87
+ {
88
+ kind: 'added';
89
+ xml: string;
90
+ }
91
+ /** Already referenced — refuse rather than write a second `<d2p1:string>`. */
92
+ | {
93
+ kind: 'duplicate';
94
+ existing: string;
95
+ }
96
+ /** No `<ModuleReferences>` element at all; the caller declines rather than inventing one. */
97
+ | {
98
+ kind: 'no-element';
99
+ }
100
+ /** Not a package folder name — see isValidModuleReferenceName. */
101
+ | {
102
+ kind: 'invalid-name';
103
+ };
104
+ export type RemoveModuleReferenceResult =
105
+ /** Removed. `xml` is the updated document, `removed` the entry as it was spelled. */
106
+ {
107
+ kind: 'removed';
108
+ xml: string;
109
+ removed: string;
110
+ }
111
+ /** Not referenced. `present` is what IS referenced, for the message. */
112
+ | {
113
+ kind: 'not-found';
114
+ present: string[];
115
+ }
116
+ /** No `<ModuleReferences>` element at all. */
117
+ | {
118
+ kind: 'no-element';
119
+ };
120
+ /**
121
+ * Could `name` be a package folder name?
122
+ *
123
+ * Checked before anything is written, because the entry is interpolated into
124
+ * `<d2p1:string>…</d2p1:string>` verbatim: whitespace inside the name writes an
125
+ * entry parseModuleReferences cannot read back (so it is neither a detected
126
+ * duplicate nor removable), an all-blank name writes an empty entry, and `&`/`<`
127
+ * write a malformed descriptor. Package folders on a real install use letters,
128
+ * digits and `_`, plus `-` in custom ones (`fm-mcp`); `.` is admitted for
129
+ * dotted ISV names, but never leading, so the name cannot step out of a
130
+ * packages root when probed as a folder.
131
+ */
132
+ export declare function isValidModuleReferenceName(name: string): boolean;
133
+ /**
134
+ * Add one `<d2p1:string>` entry to `<ModuleReferences>`.
135
+ *
136
+ * Idempotent by refusal, matching add-diagnostic-suppression: a duplicate comes
137
+ * back as 'duplicate' rather than as a silent no-op, because a caller who does
138
+ * not know the reference is already there learns more from being told than from
139
+ * a ✅ over an unchanged file. Comparison is case-insensitive — a package folder
140
+ * name is, and two entries differing only in case are one reference to xppc and
141
+ * two lines to a reviewer.
142
+ *
143
+ * Placement follows the file's OWN convention, the same way the indentation
144
+ * does. A descriptor that is already sorted gets the entry in sorted position;
145
+ * one that is not gets it appended.
146
+ *
147
+ * An earlier version of this docblock asserted that no shipped descriptor is
148
+ * sorted and always appended. That was wrong, and measurably so: across the 176
149
+ * descriptors of a 10.0.2527 PackagesLocalDirectory plus the custom and ISV
150
+ * roots beside it, 137 of the 173 with two or more references are in ascending
151
+ * case-insensitive order, and every custom model on the box was among them —
152
+ * i.e. the sorted convention is the one the files a write actually targets keep.
153
+ * Appending to a sorted list puts the new entry out of order, which is a line a
154
+ * reviewer stops at and which the next Visual Studio save silently re-sorts,
155
+ * turning one intended change into two diffs.
156
+ *
157
+ * The 36 unsorted ones (Foundation, SCMControls, several *Integration models)
158
+ * are left alone rather than tidied: re-sorting a file to add one line to it is
159
+ * the larger diff, not the smaller one.
160
+ */
161
+ export declare function addModuleReference(xml: string, moduleName: string): AddModuleReferenceResult;
162
+ /**
163
+ * Remove one `<d2p1:string>` entry from `<ModuleReferences>`, matched
164
+ * case-insensitively on its text.
165
+ *
166
+ * Removing the LAST entry collapses the element to `<ModuleReferences … />`,
167
+ * keeping its attributes — the shape a shipped descriptor with no references
168
+ * actually has, and the one that makes add-then-remove a byte-identical round
169
+ * trip on a file that started empty. (The one nuance: a list that started
170
+ * `i:nil="true"` comes back as the self-closing form WITHOUT the nil, because
171
+ * adding a reference is what dropped it. "No list" and "empty list" both mean
172
+ * the model references nothing, so nothing is lost.)
173
+ */
174
+ export declare function removeModuleReference(xml: string, moduleName: string): RemoveModuleReferenceResult;
175
+ /**
176
+ * Where a model's descriptor actually is under `packagesRoot`, or null when it
177
+ * is not there — the write side's equivalent of readModuleReferences, which
178
+ * collapses "no such file" into the same null as "unreadable".
179
+ *
180
+ * Reuses findDescriptor's probe order (package == model first, then a bounded
181
+ * sweep for an ISV model inside a differently-named package) so a write lands on
182
+ * the file the readers read.
183
+ */
184
+ export declare function findDescriptorPath(packagesRoot: string, modelName: string): {
185
+ filePath: string;
186
+ packageName: string;
187
+ } | null;
51
188
  //# sourceMappingURL=modelDescriptor.d.ts.map