d365fo-mcp 1.11.0 → 1.13.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 (82) hide show
  1. package/dist/bridge/bridgeAdapter.js +60 -2
  2. package/dist/cli/commands/bpCatalog.d.ts +25 -0
  3. package/dist/cli/commands/bpCatalog.js +287 -0
  4. package/dist/cli/commands/indexCmd.js +4 -0
  5. package/dist/cli/context.d.ts +8 -0
  6. package/dist/cli/context.js +8 -0
  7. package/dist/cli/xppConfig.d.ts +34 -0
  8. package/dist/cli/xppConfig.js +48 -0
  9. package/dist/config/configFile.d.ts +6 -0
  10. package/dist/config/configFile.js +6 -0
  11. package/dist/config/settings.js +19 -0
  12. package/dist/knowledge/bpMonikers/catalog.generated.d.ts +31 -0
  13. package/dist/knowledge/bpMonikers/catalog.generated.js +600 -0
  14. package/dist/knowledge/bpMonikers/index.d.ts +151 -0
  15. package/dist/knowledge/bpMonikers/index.js +279 -0
  16. package/dist/metadata/symbolIndex.d.ts +39 -3
  17. package/dist/metadata/symbolIndex.js +77 -25
  18. package/dist/middleware/rateLimiter.js +6 -0
  19. package/dist/prompts/codeReview.js +1 -1
  20. package/dist/prompts/systemInstructions.js +1 -1
  21. package/dist/scripts/build-database.js +78 -19
  22. package/dist/scripts/build-fts.js +78 -19
  23. package/dist/scripts/extract-metadata.js +18 -1
  24. package/dist/server/serverMode.d.ts +2 -2
  25. package/dist/server/serverMode.js +2 -2
  26. package/dist/server/toolAnnotations.js +1 -1
  27. package/dist/server/toolSchemas/d365foFile.js +11 -7
  28. package/dist/server/toolSchemas/getKnowledge.d.ts +17 -0
  29. package/dist/server/toolSchemas/getKnowledge.js +25 -3
  30. package/dist/server/toolSchemas/index.d.ts +17 -0
  31. package/dist/tools/d365foFile.d.ts +3 -2
  32. package/dist/tools/d365foFile.js +10 -4
  33. package/dist/tools/knowledge/bpMonikerHelp.d.ts +32 -0
  34. package/dist/tools/knowledge/bpMonikerHelp.js +155 -0
  35. package/dist/tools/knowledge/d365foErrorHelp.js +2 -1
  36. package/dist/tools/knowledge/extensionStrategyAdvisor.js +4 -3
  37. package/dist/tools/knowledge/getKnowledge.d.ts +9 -7
  38. package/dist/tools/knowledge/getKnowledge.js +21 -8
  39. package/dist/tools/knowledge/methodSignature.js +2 -1
  40. package/dist/tools/knowledge/xppKnowledge.js +6 -5
  41. package/dist/tools/readers/classInfo.js +24 -5
  42. package/dist/tools/readers/getMethod.js +8 -3
  43. package/dist/tools/readers/getObjectInfo.js +17 -1
  44. package/dist/tools/sdlc/buildProject.d.ts +9 -0
  45. package/dist/tools/sdlc/buildProject.js +124 -20
  46. package/dist/tools/sdlc/runBpCheck.d.ts +33 -1
  47. package/dist/tools/sdlc/runBpCheck.js +132 -12
  48. package/dist/tools/smart/codeGen.js +5 -4
  49. package/dist/tools/specs/d365foFileOpSpecs.js +184 -4
  50. package/dist/tools/specs/opSpecs.js +39 -0
  51. package/dist/tools/write/createD365File.js +3 -2
  52. package/dist/tools/write/deleteD365File.d.ts +49 -0
  53. package/dist/tools/write/deleteD365File.js +357 -0
  54. package/dist/tools/write/modifyD365File.js +862 -77
  55. package/dist/tools/write/resolveReferences.js +1 -1
  56. package/dist/tools/xml/generateMetadata.d.ts +6 -2
  57. package/dist/tools/xml/generateMetadata.js +13 -4
  58. package/dist/tools/xml/securityPrivilegeXml.d.ts +54 -0
  59. package/dist/tools/xml/securityPrivilegeXml.js +86 -0
  60. package/dist/utils/crossModelWriteGuard.d.ts +8 -16
  61. package/dist/utils/crossModelWriteGuard.js +18 -2
  62. package/dist/utils/formControlRemoval.d.ts +70 -0
  63. package/dist/utils/formControlRemoval.js +216 -0
  64. package/dist/utils/formExtensionControlXml.d.ts +159 -0
  65. package/dist/utils/formExtensionControlXml.js +592 -0
  66. package/dist/utils/formExtensionShapeValidator.js +15 -0
  67. package/dist/utils/ignoreDiagnosticListXml.d.ts +186 -0
  68. package/dist/utils/ignoreDiagnosticListXml.js +267 -0
  69. package/dist/utils/loadEnv.d.ts +4 -3
  70. package/dist/utils/loadEnv.js +5 -4
  71. package/dist/utils/methodBodyHint.d.ts +50 -0
  72. package/dist/utils/methodBodyHint.js +54 -0
  73. package/dist/utils/objectFileLookup.d.ts +19 -0
  74. package/dist/utils/objectFileLookup.js +85 -29
  75. package/dist/utils/xmlNodeTree.d.ts +54 -0
  76. package/dist/utils/xmlNodeTree.js +144 -0
  77. package/dist/workspace/contextRanker.js +7 -2
  78. package/dist/workspace/projectFile.js +57 -14
  79. package/dist/workspace/projectMembership.d.ts +17 -3
  80. package/dist/workspace/projectMembership.js +22 -7
  81. package/package.json +3 -2
  82. package/scripts/extract-bp-catalog.ps1 +321 -0
@@ -27,6 +27,7 @@ import { xppMethodSourceForXml } from '../utils/xppFormat.js';
27
27
  import { ensureXppDocComment, ensureBlankLineBeforeClosingBrace } from '../utils/xppDocGen.js';
28
28
  import { parseXppDeclaration, parseXppClassHeader } from '../metadata/xppDeclaration.js';
29
29
  import { rankCustomFirst, isExactNameMatch } from '../utils/exactMatchRanking.js';
30
+ import { COMPACT_METHODS_HINT, fullBodyHint } from '../utils/methodBodyHint.js';
30
31
  import { pageFields, fieldsHeading, fieldsFooter, createControlBudget, chargeControl, chargeSkippedSubtree, controlsFooter, } from '../utils/payloadBudget.js';
31
32
  // TABLE
32
33
  const TABLE_METHOD_PAGE_SIZE = 25;
@@ -215,13 +216,35 @@ function formatClass(cls, compact, methodOffset) {
215
216
  out += `### ${m.name}\n\n`;
216
217
  if (m.source) {
217
218
  const preview = m.source.substring(0, 500);
218
- out += `\`\`\`xpp\n${preview}${m.source.length > 500 ? '\n// ... (use get_method(include="source") for full body)' : ''}\n\`\`\`\n\n`;
219
+ // Same call syntax as COMPACT_METHODS_HINT below — two different ways to
220
+ // ask for one method body, fifteen lines apart, is the inconsistency
221
+ // this hint exists to remove.
222
+ out += `\`\`\`xpp\n${preview}${m.source.length > 500 ? `\n// ... (${fullBodyHint(m.name)})` : ''}\n\`\`\`\n\n`;
219
223
  }
220
224
  }
221
225
  }
222
226
  if (hasMore) {
223
227
  out += `> ⚠️ **${total - methodOffset - CLASS_METHOD_PAGE_SIZE} more methods.** Call again with \`methodOffset: ${methodOffset + CLASS_METHOD_PAGE_SIZE}\`.\n\n`;
224
228
  }
229
+ // compact=true is the default, and this list is the only place a caller who
230
+ // didn't read the tool schema learns that bodies exist but were withheld —
231
+ // the DB-only fallback (classInfo.ts buildDbOnlyResponse) already says this;
232
+ // the bridge path (the primary, "always available on VM" path) silently gave
233
+ // signatures with no pointer to the escape hatch, which read as "no source
234
+ // available for this class" when it was really "not requested".
235
+ //
236
+ // Gated on the RENDERED page carrying source, not on `total`. Bridge method
237
+ // source is Safe(() => method.Source) and BridgeMethodInfo.source is
238
+ // optional, so a class can come back with none: the compact:false branch
239
+ // above then prints `### name` and no code block at all (`if (m.source)`),
240
+ // which is strictly LESS than the signature line compact just gave — a round
241
+ // trip spent to lose information, on exactly the classes where the caller was
242
+ // already unsure whether source existed. The same guard covers an out-of-range
243
+ // methodOffset, where `visible` is empty and there is no signature on the page
244
+ // for "signatures only" to be describing.
245
+ if (compact && visible.some(m => m.source)) {
246
+ out += `${COMPACT_METHODS_HINT}\n\n`;
247
+ }
225
248
  return out;
226
249
  }
227
250
  // METHOD SOURCE
@@ -1117,8 +1140,20 @@ const BRIDGE_MODIFY_OPS = new Set([
1117
1140
  'modify-property',
1118
1141
  'add-enum-value', 'modify-enum-value', 'remove-enum-value',
1119
1142
  'add-control', 'add-data-source',
1143
+ // Removal of a control, and of a privilege's entry point, have no C# op either
1144
+ // (MetadataWriteService exposes no RemoveControl, and security objects have no
1145
+ // bridge write path at all) — both are served by a direct-XML writer and still
1146
+ // pass through this gate.
1147
+ 'remove-control', 'remove-entry-point',
1148
+ // AxIgnoreDiagnosticList is not an AOT object at all — MetadataWriteService has
1149
+ // no concept of it — so this is XML-only for the same structural reason as the
1150
+ // two above.
1151
+ 'remove-diagnostic-suppression', 'add-diagnostic-suppression',
1120
1152
  'add-display-method', 'add-table-method',
1121
1153
  'add-field-modification', 'add-menu-item-to-menu',
1154
+ // No C# op exists for query ranges on entities — served entirely by a
1155
+ // direct-XML writer (data-entity is already in BRIDGE_MODIFY_TYPES).
1156
+ 'add-query-range', 'remove-query-range',
1122
1157
  ]);
1123
1158
  /**
1124
1159
  * Supported object types for bridge-based modification.
@@ -1133,6 +1168,25 @@ const BRIDGE_MODIFY_TYPES = new Set([
1133
1168
  'menu-item-action', 'menu-item-display', 'menu-item-output',
1134
1169
  'menu',
1135
1170
  ]);
1171
+ /**
1172
+ * Operation → the object types it may target that BRIDGE_MODIFY_TYPES does not
1173
+ * cover, because a direct-XML writer serves the pair and the bridge never will.
1174
+ *
1175
+ * This gate is what EVERY modify operation clears before dispatch, whether or not
1176
+ * a C# op backs it, so an XML-only writer for an XML-only type has nowhere else to
1177
+ * be admitted. The pairing is per-operation rather than per-type on purpose:
1178
+ * dropping 'security-privilege' into BRIDGE_MODIFY_TYPES would also claim
1179
+ * add-method, replace-code and modify-property work on a privilege, which they do
1180
+ * not — the bridge has no write path for security objects at all (the same reason
1181
+ * they are absent from BRIDGE_CREATE_TYPES: a generic
1182
+ * Dictionary<string,string> cannot carry <EntryPoints>). The caller would then get
1183
+ * a bridge resolution failure instead of "not supported for this object type".
1184
+ */
1185
+ const XML_ONLY_MODIFY_PAIRS = {
1186
+ 'remove-entry-point': new Set(['security-privilege']),
1187
+ 'remove-diagnostic-suppression': new Set(['ignore-diagnostic-list']),
1188
+ 'add-diagnostic-suppression': new Set(['ignore-diagnostic-list']),
1189
+ };
1136
1190
  /**
1137
1191
  * Names the properties the bridge could not write, for appending to a success message.
1138
1192
  *
@@ -1157,7 +1211,11 @@ export function canBridgeCreate(objectType) {
1157
1211
  * Checks if bridge can handle this modify operation.
1158
1212
  */
1159
1213
  export function canBridgeModify(objectType, operation) {
1160
- return BRIDGE_MODIFY_TYPES.has(objectType.toLowerCase()) && BRIDGE_MODIFY_OPS.has(operation.toLowerCase());
1214
+ const type = objectType.toLowerCase();
1215
+ const op = operation.toLowerCase();
1216
+ if (!BRIDGE_MODIFY_OPS.has(op))
1217
+ return false;
1218
+ return BRIDGE_MODIFY_TYPES.has(type) || (XML_ONLY_MODIFY_PAIRS[op]?.has(type) ?? false);
1161
1219
  }
1162
1220
  /**
1163
1221
  * Give a create's X++ the same doc comments and indentation the XML fallback gives it.
@@ -0,0 +1,25 @@
1
+ import { commandExists, runExe } from '../exec.js';
2
+ import { Target } from '../target.js';
3
+ /** Injectable for tests — real implementations by default. */
4
+ export interface BpCatalogDeps {
5
+ commandExists: typeof commandExists;
6
+ runExe: typeof runExe;
7
+ }
8
+ /**
9
+ * Regenerate this target's BP-moniker catalog when its resolved D365FO
10
+ * version has moved since the catalog file was last stamped (including
11
+ * "never generated" — covers first-time instance creation). A no-op on
12
+ * every other rebuild. Never throws and never fails the caller's reindex —
13
+ * a missing/stale BP catalog degrades one knowledge tool, not the server.
14
+ *
15
+ * "Never throws" is enforced here rather than left to the callee getting every
16
+ * path right. rebuildIndex() awaits this as its last step, unwrapped, *after*
17
+ * the multi-minute extract and database build have already succeeded and
18
+ * logged "Index rebuilt" — so anything escaping would turn a finished rebuild
19
+ * into a crashed command. And plenty can escape: runExe rejects on the child
20
+ * process's own 'error' event (commandExists only rules out ENOENT, not an
21
+ * EACCES on the interpreter), and saveStore writes two files that a locked or
22
+ * read-only instance config will refuse.
23
+ */
24
+ export declare function ensureBpCatalogFresh(target: Target, deps?: BpCatalogDeps): Promise<void>;
25
+ //# sourceMappingURL=bpCatalog.d.ts.map
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Keeps one target's BP-moniker catalog (src/knowledge/bpMonikers/) matching
3
+ * its own pinned D365FO version, instead of every instance sharing the one
4
+ * snapshot committed in catalog.generated.ts (see that module's docblock).
5
+ *
6
+ * Called as a step of rebuildIndex() (indexCmd.ts) — the one place already
7
+ * reached by instance creation, upgrade, routine rebuild, `update`, and the
8
+ * first-time setup wizard. Regeneration only actually runs when the stamped
9
+ * version in the target's existing catalog file differs from what this
10
+ * target resolves to now (or the file does not exist yet); every other call
11
+ * is a cheap read-and-compare with no subprocess spawned.
12
+ *
13
+ * Two things this module will not do, both because the result would be
14
+ * self-perpetuating — whatever it stamps with the current version key is what
15
+ * every later rebuild treats as up to date: extract from an install that is
16
+ * not this target's (resolveSource), and accept an extraction that only
17
+ * partially read the one that is (verifyExtraction).
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs';
21
+ import { basename, join, resolve } from 'node:path';
22
+ import { settingByPath } from '../../config/settings.js';
23
+ import { findPackagesRoot } from '../../utils/packagesRoot.js';
24
+ import { commandExists, runExe } from '../exec.js';
25
+ import { paths } from '../context.js';
26
+ import { readPath, readSetting, saveStore, writeSetting } from '../settingsStore.js';
27
+ import { p } from '../ui.js';
28
+ import { isUdeTarget, resolvePinnedXppConfig } from '../xppConfig.js';
29
+ const bpCatalogPathSetting = settingByPath('index.bpCatalogPath');
30
+ const packagePathSetting = settingByPath('environment.packagePath');
31
+ /**
32
+ * Relative on purpose — instance path settings stay portable so an instance
33
+ * folder can be renamed or moved (see configBaseDir in config/configFile.ts).
34
+ */
35
+ const DEFAULT_CATALOG_RELATIVE = './data/bp-moniker-catalog.json';
36
+ /** Where this target's real D365FO version + packages root come from. */
37
+ function resolveSource(target) {
38
+ const xppConfig = resolvePinnedXppConfig(target.store);
39
+ if (xppConfig) {
40
+ // No frameworkDirectory means the config JSON could not be read —
41
+ // listXppConfigs() keeps those entries deliberately. Passing '' would not
42
+ // be "use the default", it would hand the script an empty -PackagesPath,
43
+ // whose own fallback auto-detects the NEWEST PackagesLocalDirectory on the
44
+ // box; the result then gets stamped with THIS target's version and treated
45
+ // as current. A catalog from the wrong install is worse than no catalog.
46
+ if (!xppConfig.frameworkDirectory)
47
+ return null;
48
+ return { versionKey: xppConfig.version, packagesPath: xppConfig.frameworkDirectory };
49
+ }
50
+ // A null from resolvePinnedXppConfig does NOT mean "traditional". It also
51
+ // covers a UDE target whose pin names a config that is gone — the
52
+ // stale-after-UDE-upgrade state `instance upgrade` exists to fix — and that
53
+ // function returns null there *deliberately*, rather than substituting a
54
+ // different environment. Falling through to the box-wide scan below would
55
+ // undo exactly that: findPackagesRoot() ranks every <drive>:\AosService\
56
+ // PackagesLocalDirectory on the machine and hands back the most plausible
57
+ // one, which on a mixed box is some other install entirely. Its catalog
58
+ // would then be stamped with this target's key and treated as current —
59
+ // the same "catalog from the wrong install" the branch above refuses.
60
+ if (isUdeTarget(target.store))
61
+ return null;
62
+ // Traditional environment: no version string on disk anywhere, so the state
63
+ // of the files the catalog is extracted FROM stands in for "has this install
64
+ // changed since we last extracted".
65
+ const packagesPath = readSetting(target.store, packagePathSetting)
66
+ || findPackagesRoot()
67
+ || undefined;
68
+ if (!packagesPath)
69
+ return null;
70
+ const versionKey = ruleSourceKey(packagesPath);
71
+ return versionKey ? { versionKey, packagesPath } : null;
72
+ }
73
+ /**
74
+ * A key that moves when the rule DLLs move — the actual input to half the
75
+ * catalog, and the half that cannot be reconstructed from names alone.
76
+ *
77
+ * The obvious cheap key, `statSync(packagesPath/bin).mtimeMs`, does not track
78
+ * that: a directory's mtime changes when its *direct* children are added,
79
+ * removed or renamed, so a platform hotfix that rewrites DLLs inside
80
+ * bin\BPExtensions\ in place leaves bin\ untouched, the key matches, and the
81
+ * catalog is never regenerated — the exact staleness the per-instance catalog
82
+ * exists to fix. Size and mtime of each rule DLL move whenever its content
83
+ * does.
84
+ *
85
+ * The file set mirrors what extract-bp-catalog.ps1 itself reflects over:
86
+ * bin\*.dll whose name contains 'BestPractice', plus every DLL in
87
+ * bin\BPExtensions. Measured on a real install that is 25 files, reached by
88
+ * two readdirs and 25 stats in ~5 ms — this runs on every rebuild, so it has
89
+ * to stay far cheaper than the multi-minute scan it decides against.
90
+ *
91
+ * What it deliberately does NOT cover: each model's AxRuleSet\BPRules.xml, the
92
+ * source of the canonical names. Finding those means recursing the whole
93
+ * packages root, which is most of the cost of the extraction itself. Neither
94
+ * did the bin\ mtime it replaces, and in practice a platform update that
95
+ * changes the rule set ships new rule DLLs with it.
96
+ */
97
+ function ruleSourceKey(packagesPath) {
98
+ // join(), not a hardcoded '\bin' — this path is only ever real on Windows in
99
+ // production, but the test suite (and CI) exercises it on Linux too, where a
100
+ // literal backslash is just another filename character, not a separator.
101
+ const binDir = join(packagesPath, 'bin');
102
+ const extensionsDir = join(binDir, 'BPExtensions');
103
+ const parts = [];
104
+ for (const dir of [binDir, extensionsDir]) {
105
+ let names;
106
+ try {
107
+ names = readdirSync(dir);
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ for (const name of names.sort()) {
113
+ if (!/\.dll$/i.test(name))
114
+ continue;
115
+ if (dir === binDir && !/BestPractice/i.test(name))
116
+ continue;
117
+ try {
118
+ const stat = statSync(join(dir, name));
119
+ parts.push(`${name}:${stat.size}:${stat.mtimeMs}`);
120
+ }
121
+ catch { /* vanished between readdir and stat — just leave it out */ }
122
+ }
123
+ }
124
+ // No rule DLL anywhere means this is not a usable packages root, so there is
125
+ // nothing to extract and nothing to key on. Returning a key for the empty set
126
+ // would make every such path agree with every other one.
127
+ if (parts.length === 0)
128
+ return null;
129
+ return `rules:${createHash('sha1').update(parts.join('|')).digest('hex').slice(0, 16)}`;
130
+ }
131
+ /** The version this target's *existing* catalog file was stamped with, if any. */
132
+ function existingVersionKey(catalogPath) {
133
+ if (!existsSync(catalogPath))
134
+ return null;
135
+ try {
136
+ // Strip a leading BOM before parsing: the script writes BOM-less UTF-8, but
137
+ // a catalog left behind by an older copy of it (or hand-edited in a Windows
138
+ // editor) still carries one, and JSON.parse throws on it. Throwing here is
139
+ // not visible — it just reads as "no catalog yet" and re-runs the whole scan.
140
+ const parsed = JSON.parse(readFileSync(catalogPath, 'utf-8').replace(/^\uFEFF/, ''));
141
+ return typeof parsed.version === 'string' ? parsed.version : null;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ /**
148
+ * Why a freshly extracted catalog must not be trusted as this target's current
149
+ * one — or null when it looks like a complete extraction.
150
+ *
151
+ * Exit code 0 does not mean "complete". Both of the script's scans are
152
+ * best-effort on purpose (`-ErrorAction SilentlyContinue`, a per-file catch on
153
+ * BPRules.xml, a per-DLL catch on assembly load), so a run that could read half
154
+ * the models, or none of the rule DLLs, still finishes and exits 0. Stamping
155
+ * that result is worse than not regenerating at all: the stamp matches on every
156
+ * later rebuild, so it is never retried, and loadCatalog() only rejects an
157
+ * exactly-empty override — a merely truncated one REPLACES the compiled
158
+ * snapshot, and bp_moniker starts answering "not in the extracted catalog" for
159
+ * monikers that are real.
160
+ *
161
+ * The failure counts are the script's own (`sources`), not a guess: measured
162
+ * against a real 214-model PackagesLocalDirectory, a healthy box skips zero of
163
+ * its 144 BPRules.xml files and zero of its 25 rule DLLs, so any skip at all is
164
+ * signal. The content checks below stand in for that on a catalog produced by
165
+ * an older copy of the script, which carries no `sources` block.
166
+ */
167
+ function verifyExtraction(catalogPath) {
168
+ let parsed;
169
+ try {
170
+ // Same BOM strip as existingVersionKey — a catalog an older copy of the
171
+ // script produced under PowerShell 5.1 carries one, and this must reject a
172
+ // partial extraction, not a readable-but-BOM'd one.
173
+ parsed = JSON.parse(readFileSync(catalogPath, 'utf-8').replace(/^\uFEFF/, ''));
174
+ }
175
+ catch (err) {
176
+ return `it could not be read back (${err.message})`;
177
+ }
178
+ if (!Array.isArray(parsed.entries))
179
+ return 'it carries no entries array';
180
+ const { ruleSetFailures = 0, dllFailures = 0, ruleSetFiles = 0, ruleDlls = 0 } = parsed.sources ?? {};
181
+ if (ruleSetFailures > 0 || dllFailures > 0) {
182
+ return `the extraction skipped ${ruleSetFailures} of ${ruleSetFiles} BPRules.xml files and ${dllFailures} of ${ruleDlls} rule DLLs`;
183
+ }
184
+ const entries = parsed.entries;
185
+ if (entries.length === 0)
186
+ return 'it contains no monikers at all';
187
+ // Either source failing wholesale leaves a catalog that still looks populated
188
+ // but has lost the half that answers a question: no canonical name means no
189
+ // AxRuleSet was read, so `validate` cannot confirm anything; no rule text
190
+ // means no DLL yielded resources, so `search` matches nothing.
191
+ if (!entries.some(e => e.canonical))
192
+ return 'not one of its monikers came from an AxRuleSet/BPRules.xml';
193
+ if (!entries.some(e => e.message || e.description))
194
+ return 'not one of its monikers carries rule text';
195
+ return null;
196
+ }
197
+ const defaultDeps = { commandExists, runExe };
198
+ /**
199
+ * Regenerate this target's BP-moniker catalog when its resolved D365FO
200
+ * version has moved since the catalog file was last stamped (including
201
+ * "never generated" — covers first-time instance creation). A no-op on
202
+ * every other rebuild. Never throws and never fails the caller's reindex —
203
+ * a missing/stale BP catalog degrades one knowledge tool, not the server.
204
+ *
205
+ * "Never throws" is enforced here rather than left to the callee getting every
206
+ * path right. rebuildIndex() awaits this as its last step, unwrapped, *after*
207
+ * the multi-minute extract and database build have already succeeded and
208
+ * logged "Index rebuilt" — so anything escaping would turn a finished rebuild
209
+ * into a crashed command. And plenty can escape: runExe rejects on the child
210
+ * process's own 'error' event (commandExists only rules out ENOENT, not an
211
+ * EACCES on the interpreter), and saveStore writes two files that a locked or
212
+ * read-only instance config will refuse.
213
+ */
214
+ export async function ensureBpCatalogFresh(target, deps = defaultDeps) {
215
+ try {
216
+ await refreshCatalog(target, deps);
217
+ }
218
+ catch (err) {
219
+ p.log.warn(`BP catalog: skipped for ${target.label} (${err.message}) — the index rebuild itself is unaffected.`);
220
+ }
221
+ }
222
+ async function refreshCatalog(target, deps) {
223
+ const source = resolveSource(target);
224
+ if (!source) {
225
+ p.log.warn(isUdeTarget(target.store)
226
+ ? `BP catalog: could not resolve ${target.label}'s own D365FO install (its XPP config is missing or unreadable) — skipped rather than extracting from another install on this box. \`d365fo-mcp instance upgrade\` repoints a pin left behind by a UDE upgrade.`
227
+ : `BP catalog: could not resolve a packages path for ${target.label} — skipped.`);
228
+ return;
229
+ }
230
+ // The setting carries no registry default (see settings.ts for why), so this
231
+ // fallback is the normal path until the first successful extraction writes
232
+ // the value below.
233
+ const catalogPath = readPath(target.store, bpCatalogPathSetting, resolve(target.store.baseDir, DEFAULT_CATALOG_RELATIVE));
234
+ if (existingVersionKey(catalogPath) === source.versionKey) {
235
+ p.log.info(`BP catalog up to date (${target.label}).`);
236
+ return;
237
+ }
238
+ let shell = 'pwsh';
239
+ if (!await deps.commandExists(shell)) {
240
+ shell = 'powershell';
241
+ if (!await deps.commandExists(shell)) {
242
+ p.log.warn(`BP catalog: neither pwsh nor powershell is on PATH for ${target.label} — skipped, will retry next rebuild.`);
243
+ return;
244
+ }
245
+ }
246
+ p.log.step(`Refreshing BP moniker catalog (${target.label}, ${basename(source.packagesPath)})…`);
247
+ // Extract beside the real catalog, not over it. The script writes its output
248
+ // in one go with no staging of its own, so pointing it straight at the live
249
+ // file means a partial run destroys a good catalog before anything can judge
250
+ // it — and the caller cannot put it back. The move below is the only thing
251
+ // that makes this target's catalog change.
252
+ const stagedPath = `${catalogPath}.new`;
253
+ const exitCode = await deps.runExe(shell, [
254
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', paths.extractBpCatalogScript,
255
+ '-PackagesPath', source.packagesPath,
256
+ '-OutFile', stagedPath,
257
+ '-Version', source.versionKey,
258
+ ]);
259
+ if (exitCode !== 0) {
260
+ rmSync(stagedPath, { force: true });
261
+ p.log.warn(`BP catalog: extraction failed for ${target.label} (exit ${exitCode}) — keeping the previous catalog.`);
262
+ return;
263
+ }
264
+ const problem = verifyExtraction(stagedPath);
265
+ if (problem) {
266
+ // Discarded rather than stamped: the file carries this target's current
267
+ // version key, so keeping it would make every later rebuild a no-op and
268
+ // freeze a half-read catalog in place. Dropping it leaves the previous
269
+ // catalog (or the compiled snapshot) in service and the version key stale,
270
+ // which is exactly what makes the next rebuild try again.
271
+ rmSync(stagedPath, { force: true });
272
+ p.log.warn(`BP catalog: discarded the extraction for ${target.label} — ${problem}. Keeping the previous catalog; the next rebuild will retry.`);
273
+ return;
274
+ }
275
+ renameSync(stagedPath, catalogPath);
276
+ // Write the RELATIVE literal, not the resolved catalogPath: an absolute path
277
+ // baked into the instance config survives neither a folder rename nor a move,
278
+ // and toEnvRecord() resolves a relative one against store.baseDir on its way
279
+ // into BP_CATALOG_PATH anyway. This write is also what turns the override on
280
+ // at all — the setting has no registry default.
281
+ if (readSetting(target.store, bpCatalogPathSetting) === undefined) {
282
+ writeSetting(target.store, bpCatalogPathSetting, DEFAULT_CATALOG_RELATIVE);
283
+ saveStore(target.store);
284
+ }
285
+ p.log.success(`BP catalog refreshed (${target.label}).`);
286
+ }
287
+ //# sourceMappingURL=bpCatalog.js.map
@@ -4,6 +4,7 @@
4
4
  * instances/rebuild-instance.ps1 minus the git-pull step (that lives in
5
5
  * `d365fo-mcp update`).
6
6
  */
7
+ import { ensureBpCatalogFresh } from './bpCatalog.js';
7
8
  import { dataRoot, installMode, isWindows, paths } from '../context.js';
8
9
  import { runNode } from '../exec.js';
9
10
  import { listInstances } from '../instances.js';
@@ -47,6 +48,9 @@ export async function rebuildIndex(target) {
47
48
  return false;
48
49
  }
49
50
  p.log.success(`Index rebuilt: ${target.label}`);
51
+ // Best-effort: a stale/missing BP catalog degrades one knowledge tool, not
52
+ // the metadata index rebuild that just succeeded above.
53
+ await ensureBpCatalogFresh(target);
50
54
  return true;
51
55
  }
52
56
  export async function indexCommand(instanceName, opts) {
@@ -84,6 +84,14 @@ export declare const paths: {
84
84
  /** esbuild bundles of the two scripts above — what an npm install ships instead of the sources. */
85
85
  extractScriptDist: string;
86
86
  buildDbScriptDist: string;
87
+ /**
88
+ * Not compiled, not bundled — a raw PowerShell script that reflects over the
89
+ * .NET-authored BP-rule DLLs to pull real message/description text, which
90
+ * Node cannot do on its own. Same path for a git checkout and an npm
91
+ * install; ships as a listed file in package.json rather than through the
92
+ * dist/ pipeline the .ts scripts above use.
93
+ */
94
+ extractBpCatalogScript: string;
87
95
  };
88
96
  /**
89
97
  * The exact command that builds the bridge for this installation, for every
@@ -153,6 +153,14 @@ export const paths = {
153
153
  /** esbuild bundles of the two scripts above — what an npm install ships instead of the sources. */
154
154
  extractScriptDist: resolve(repoRoot, 'dist', 'scripts', 'extract-metadata.js'),
155
155
  buildDbScriptDist: resolve(repoRoot, 'dist', 'scripts', 'build-database.js'),
156
+ /**
157
+ * Not compiled, not bundled — a raw PowerShell script that reflects over the
158
+ * .NET-authored BP-rule DLLs to pull real message/description text, which
159
+ * Node cannot do on its own. Same path for a git checkout and an npm
160
+ * install; ships as a listed file in package.json rather than through the
161
+ * dist/ pipeline the .ts scripts above use.
162
+ */
163
+ extractBpCatalogScript: resolve(repoRoot, 'scripts', 'extract-bp-catalog.ps1'),
156
164
  };
157
165
  /**
158
166
  * The exact command that builds the bridge for this installation, for every
@@ -13,6 +13,40 @@ export interface XppConfig {
13
13
  export declare function xppConfigDir(): string | null;
14
14
  /** All versioned configs, newest first. Empty when the directory is absent. */
15
15
  export declare function listXppConfigs(): XppConfig[];
16
+ /**
17
+ * The full {@link XppConfig} a UDE target resolves to: the pinned one, or the
18
+ * newest available when nothing is pinned. Null for a traditional target, when
19
+ * no config exists at all, and — deliberately — when the pin names a config
20
+ * that is gone.
21
+ *
22
+ * Mirrors XppConfigProvider.getActiveConfig exactly, including that last case:
23
+ * it matches either form of the name and returns null rather than substituting
24
+ * a different environment. A pin that no longer resolves is the
25
+ * stale-after-UDE-upgrade state isXppConfigStale() flags and `instance upgrade`
26
+ * exists to fix; answering it with the newest config would have a caller
27
+ * extract from 10.0.2500 and stamp the result as this target's, while its index
28
+ * and server still reference the old pin.
29
+ */
30
+ export declare function resolvePinnedXppConfig(store: SettingsStore): XppConfig | null;
31
+ /**
32
+ * Is this target a UDE target at all — i.e. does a null from
33
+ * {@link resolvePinnedXppConfig} mean "its pin does not resolve" rather than
34
+ * "this is a traditional install"?
35
+ *
36
+ * That function returns null for three different reasons and a caller cannot
37
+ * tell them apart, which matters because the two answers call for opposite
38
+ * behaviour: a traditional target legitimately falls back to detecting the
39
+ * packages root on this box, while for a UDE target that fallback throws away
40
+ * the very guarantee the null was there to provide (see resolveSource in
41
+ * commands/bpCatalog.ts, and the docblock above).
42
+ *
43
+ * Mirrors the detection settings.ts documents for an unset environment.type:
44
+ * UDE when XPP config files exist in %LOCALAPPDATA%\Microsoft\Dynamics365\
45
+ * XPPConfig. So a box with no configs at all is traditional — the one case
46
+ * where falling through is correct — and anything else with an explicit
47
+ * `traditional` type is taken at its word.
48
+ */
49
+ export declare function isUdeTarget(store: SettingsStore): boolean;
16
50
  /**
17
51
  * Expand a short config name (e.g. "myenv-dev") to the newest full versioned
18
52
  * name ("myenv-dev___10.0.2345.153") so a later staleness check is a plain
@@ -54,6 +54,54 @@ function pinnedConfigName(store) {
54
54
  const value = readSetting(store, xppConfigNameSetting);
55
55
  return typeof value === 'string' && value ? value : null;
56
56
  }
57
+ /**
58
+ * The full {@link XppConfig} a UDE target resolves to: the pinned one, or the
59
+ * newest available when nothing is pinned. Null for a traditional target, when
60
+ * no config exists at all, and — deliberately — when the pin names a config
61
+ * that is gone.
62
+ *
63
+ * Mirrors XppConfigProvider.getActiveConfig exactly, including that last case:
64
+ * it matches either form of the name and returns null rather than substituting
65
+ * a different environment. A pin that no longer resolves is the
66
+ * stale-after-UDE-upgrade state isXppConfigStale() flags and `instance upgrade`
67
+ * exists to fix; answering it with the newest config would have a caller
68
+ * extract from 10.0.2500 and stamp the result as this target's, while its index
69
+ * and server still reference the old pin.
70
+ */
71
+ export function resolvePinnedXppConfig(store) {
72
+ if (readSetting(store, envTypeSetting) === 'traditional')
73
+ return null;
74
+ const configs = listXppConfigs();
75
+ if (configs.length === 0)
76
+ return null;
77
+ const configName = pinnedConfigName(store);
78
+ if (!configName)
79
+ return configs[0];
80
+ return configs.find(c => c.fullName === configName || c.name === configName) ?? null;
81
+ }
82
+ /**
83
+ * Is this target a UDE target at all — i.e. does a null from
84
+ * {@link resolvePinnedXppConfig} mean "its pin does not resolve" rather than
85
+ * "this is a traditional install"?
86
+ *
87
+ * That function returns null for three different reasons and a caller cannot
88
+ * tell them apart, which matters because the two answers call for opposite
89
+ * behaviour: a traditional target legitimately falls back to detecting the
90
+ * packages root on this box, while for a UDE target that fallback throws away
91
+ * the very guarantee the null was there to provide (see resolveSource in
92
+ * commands/bpCatalog.ts, and the docblock above).
93
+ *
94
+ * Mirrors the detection settings.ts documents for an unset environment.type:
95
+ * UDE when XPP config files exist in %LOCALAPPDATA%\Microsoft\Dynamics365\
96
+ * XPPConfig. So a box with no configs at all is traditional — the one case
97
+ * where falling through is correct — and anything else with an explicit
98
+ * `traditional` type is taken at its word.
99
+ */
100
+ export function isUdeTarget(store) {
101
+ if (readSetting(store, envTypeSetting) === 'traditional')
102
+ return false;
103
+ return listXppConfigs().length > 0;
104
+ }
57
105
  /**
58
106
  * Expand a short config name (e.g. "myenv-dev") to the newest full versioned
59
107
  * name ("myenv-dev___10.0.2345.153") so a later staleness check is a plain
@@ -62,6 +62,12 @@ export declare function toEnvRecord(files: Pick<ResolvedConfigFiles, 'baseDir' |
62
62
  *
63
63
  * Emitting the defaults here pins them to the installation directory instead.
64
64
  * A checkout is its own data directory, so its paths do not move.
65
+ *
66
+ * BP_CATALOG_PATH is deliberately NOT one of them: index.bpCatalogPath carries
67
+ * no registry default precisely so that "unset" keeps meaning "use the
68
+ * compiled-in catalog". Giving it one here would make the variable permanently
69
+ * set and every install that has never regenerated a catalog warn about a
70
+ * missing file on each start.
65
71
  */
66
72
  export declare function defaultPathEnv(baseDir: string): Record<string, string>;
67
73
  /** Write the config file, creating its directory. Keys are emitted in registry order. */
@@ -142,6 +142,12 @@ export function toEnvRecord(files) {
142
142
  *
143
143
  * Emitting the defaults here pins them to the installation directory instead.
144
144
  * A checkout is its own data directory, so its paths do not move.
145
+ *
146
+ * BP_CATALOG_PATH is deliberately NOT one of them: index.bpCatalogPath carries
147
+ * no registry default precisely so that "unset" keeps meaning "use the
148
+ * compiled-in catalog". Giving it one here would make the variable permanently
149
+ * set and every install that has never regenerated a catalog warn about a
150
+ * missing file on each start.
145
151
  */
146
152
  export function defaultPathEnv(baseDir) {
147
153
  const out = {};
@@ -331,6 +331,25 @@ export const SETTINGS = [
331
331
  description: 'Working folder for the XML dumped during extraction, before it is loaded into the database.',
332
332
  default: './extracted-metadata',
333
333
  },
334
+ {
335
+ path: 'index.bpCatalogPath',
336
+ env: 'BP_CATALOG_PATH',
337
+ section: 'index',
338
+ tier: 'advanced',
339
+ type: 'path',
340
+ label: 'BP moniker catalog file',
341
+ description: 'Per-instance JSON catalog of real BP-check monikers, extracted from this instance\'s own D365FO version ' +
342
+ '(scripts/extract-bp-catalog.ps1). Falls back to the compiled-in snapshot when absent — this setting is only ' +
343
+ 'written once an instance has regenerated its own catalog.',
344
+ // Deliberately no `default`. defaultPathEnv() projects every path setting
345
+ // that has a string default onto process.env, which would make
346
+ // BP_CATALOG_PATH permanently set — the "unset → compiled-in catalog"
347
+ // branch in bpMonikers/loadCatalog() would be unreachable and every
348
+ // install that has never regenerated a catalog (all existing ones, every
349
+ // Linux deployment, every checkout) would warn about a missing file on
350
+ // each start. ensureBpCatalogFresh writes the value after a successful
351
+ // extraction instead, which is what the description above promises.
352
+ },
334
353
  {
335
354
  path: 'index.labelSortOrder',
336
355
  env: 'LABEL_SORT_ORDER',
@@ -0,0 +1,31 @@
1
+ /**
2
+ * GENERATED FILE — do not hand-edit. Regenerate with:
3
+ * pwsh scripts/extract-bp-catalog.ps1
4
+ *
5
+ * BP-rule moniker catalog, extracted from a local D365FO install:
6
+ * - `canonical: true` monikers come from the union of every model's
7
+ * <Model>/<Model>/AxRuleSet/BPRules.xml — the authoritative name list.
8
+ * - `message`/`description` come from the .NET-authored rule DLLs'
9
+ * resx-backed resource classes (bin/BPExtensions/*.dll and a couple of
10
+ * core bin/*.dll) where the rule author provided one. A `null` there
11
+ * means "not found in a resource class", NOT "not a real rule".
12
+ * - Presence in this file does NOT by itself mean "BP rule". The resource
13
+ * dump also yields upgrade- and form-conversion-tool messages, which come
14
+ * out with `canonical: false`. `canonical` is the field that answers
15
+ * "is this a BP rule".
16
+ *
17
+ * Extracted from: C:\Users\laeliand\AppData\Local\Microsoft\Dynamics365\10.0.2527.174\PackagesLocalDirectory
18
+ * Generated at: (stamp with the actual date when regenerating — omitted
19
+ * here so re-running with no real change produces no diff)
20
+ */
21
+ export interface BpMonikerEntry {
22
+ moniker: string;
23
+ /** Message template (often with '{0}'-style placeholders), or null if not found in a resource class. */
24
+ message: string | null;
25
+ /** What the rule checks, or null if not found in a resource class. */
26
+ description: string | null;
27
+ /** True if this moniker appears in at least one model's AxRuleSet/BPRules.xml. */
28
+ canonical: boolean;
29
+ }
30
+ export declare const BP_MONIKER_CATALOG: BpMonikerEntry[];
31
+ //# sourceMappingURL=catalog.generated.d.ts.map