d365fo-mcp 1.10.1 → 1.12.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 (103) hide show
  1. package/dist/bridge/bridgeAdapter.js +37 -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/doctor.d.ts +52 -1
  5. package/dist/cli/commands/doctor.js +156 -11
  6. package/dist/cli/commands/indexCmd.js +4 -0
  7. package/dist/cli/context.d.ts +8 -0
  8. package/dist/cli/context.js +8 -0
  9. package/dist/cli/settingsStore.d.ts +7 -0
  10. package/dist/cli/settingsStore.js +20 -0
  11. package/dist/cli/xppConfig.d.ts +34 -0
  12. package/dist/cli/xppConfig.js +48 -0
  13. package/dist/config/configFile.d.ts +6 -0
  14. package/dist/config/configFile.js +6 -0
  15. package/dist/config/settings.d.ts +18 -9
  16. package/dist/config/settings.js +44 -11
  17. package/dist/index.js +20 -2
  18. package/dist/knowledge/bpMonikers/catalog.generated.d.ts +31 -0
  19. package/dist/knowledge/bpMonikers/catalog.generated.js +600 -0
  20. package/dist/knowledge/bpMonikers/index.d.ts +151 -0
  21. package/dist/knowledge/bpMonikers/index.js +279 -0
  22. package/dist/knowledge/tableDataMethods.d.ts +47 -0
  23. package/dist/knowledge/tableDataMethods.js +159 -0
  24. package/dist/metadata/labelParser.js +1 -1
  25. package/dist/metadata/symbolIndex.d.ts +68 -3
  26. package/dist/metadata/symbolIndex.js +123 -28
  27. package/dist/metadata/types.d.ts +21 -3
  28. package/dist/metadata/xmlParser.d.ts +13 -0
  29. package/dist/metadata/xmlParser.js +32 -7
  30. package/dist/metadata/xppDeclaration.d.ts +18 -0
  31. package/dist/metadata/xppDeclaration.js +14 -0
  32. package/dist/middleware/apiKeyAuth.d.ts +37 -0
  33. package/dist/middleware/apiKeyAuth.js +85 -2
  34. package/dist/middleware/rateLimiter.js +6 -0
  35. package/dist/prompts/codeReview.js +1 -1
  36. package/dist/prompts/systemInstructions.js +1 -1
  37. package/dist/scripts/build-database.js +147 -27
  38. package/dist/scripts/build-fts.js +147 -27
  39. package/dist/scripts/extract-metadata.js +60 -13
  40. package/dist/server/toolSchemas/getKnowledge.d.ts +17 -0
  41. package/dist/server/toolSchemas/getKnowledge.js +25 -3
  42. package/dist/server/toolSchemas/index.d.ts +17 -0
  43. package/dist/tools/analysis/labelSearchHistory.d.ts +27 -1
  44. package/dist/tools/analysis/labelSearchHistory.js +58 -1
  45. package/dist/tools/analysis/prefixDiagnostics.js +1 -1
  46. package/dist/tools/analysis/searchLabels.js +8 -2
  47. package/dist/tools/analysis/validateFormPattern.d.ts +13 -0
  48. package/dist/tools/analysis/validateFormPattern.js +49 -16
  49. package/dist/tools/analysis/validateObjectNaming.js +24 -12
  50. package/dist/tools/analysis/validateXpp.d.ts +2 -0
  51. package/dist/tools/analysis/validateXpp.js +178 -0
  52. package/dist/tools/knowledge/bpMonikerHelp.d.ts +32 -0
  53. package/dist/tools/knowledge/bpMonikerHelp.js +149 -0
  54. package/dist/tools/knowledge/d365foErrorHelp.js +2 -1
  55. package/dist/tools/knowledge/extensionStrategyAdvisor.js +4 -3
  56. package/dist/tools/knowledge/getKnowledge.d.ts +9 -7
  57. package/dist/tools/knowledge/getKnowledge.js +21 -8
  58. package/dist/tools/knowledge/methodSignature.js +27 -1
  59. package/dist/tools/knowledge/xppKnowledge.d.ts +2 -0
  60. package/dist/tools/knowledge/xppKnowledge.js +177 -11
  61. package/dist/tools/labels.js +24 -4
  62. package/dist/tools/prepare/prepareChange.js +16 -4
  63. package/dist/tools/prepare/prepareCreate.js +8 -0
  64. package/dist/tools/readers/classInfo.js +34 -5
  65. package/dist/tools/readers/getMethod.js +8 -3
  66. package/dist/tools/readers/getObjectInfo.js +17 -1
  67. package/dist/tools/readers/getWorkspaceInfo.js +9 -4
  68. package/dist/tools/sdlc/buildProject.d.ts +9 -0
  69. package/dist/tools/sdlc/buildProject.js +124 -20
  70. package/dist/tools/sdlc/runBpCheck.d.ts +33 -1
  71. package/dist/tools/sdlc/runBpCheck.js +167 -18
  72. package/dist/tools/sdlc/undoLastModification.js +26 -8
  73. package/dist/tools/sdlc/updateSymbolIndex.d.ts +0 -13
  74. package/dist/tools/sdlc/updateSymbolIndex.js +87 -2
  75. package/dist/tools/smart/codeGen.js +5 -4
  76. package/dist/tools/smart/generateSmartForm.js +13 -2
  77. package/dist/tools/specs/d365foFileOpSpecs.js +15 -3
  78. package/dist/tools/specs/opSpecs.js +1 -1
  79. package/dist/tools/write/createD365File.js +13 -4
  80. package/dist/tools/write/modifyD365File.d.ts +16 -0
  81. package/dist/tools/write/modifyD365File.js +186 -72
  82. package/dist/tools/write/resolveReferences.js +1 -1
  83. package/dist/tools/xml/generateMetadata.d.ts +6 -2
  84. package/dist/tools/xml/generateMetadata.js +13 -4
  85. package/dist/utils/buildMarker.d.ts +17 -2
  86. package/dist/utils/buildMarker.js +27 -12
  87. package/dist/utils/effectivePrefix.js +1 -1
  88. package/dist/utils/formExtensionControlXml.d.ts +147 -0
  89. package/dist/utils/formExtensionControlXml.js +706 -0
  90. package/dist/utils/formExtensionShapeValidator.js +15 -0
  91. package/dist/utils/loadEnv.d.ts +4 -3
  92. package/dist/utils/loadEnv.js +5 -4
  93. package/dist/utils/methodBodyHint.d.ts +50 -0
  94. package/dist/utils/methodBodyHint.js +54 -0
  95. package/dist/utils/modelClassifier.js +10 -3
  96. package/dist/utils/modelToken.d.ts +30 -0
  97. package/dist/utils/modelToken.js +32 -0
  98. package/dist/utils/objectNaming.js +46 -10
  99. package/dist/utils/packagesRoot.d.ts +31 -0
  100. package/dist/utils/packagesRoot.js +33 -0
  101. package/dist/workspace/contextRanker.js +7 -2
  102. package/package.json +3 -2
  103. package/scripts/extract-bp-catalog.ps1 +321 -0
@@ -25,8 +25,9 @@ import * as debouncedRefresh from './debouncedRefresh.js';
25
25
  import { debugLog } from '../utils/logger.js';
26
26
  import { xppMethodSourceForXml } from '../utils/xppFormat.js';
27
27
  import { ensureXppDocComment, ensureBlankLineBeforeClosingBrace } from '../utils/xppDocGen.js';
28
- import { parseXppDeclaration } from '../metadata/xppDeclaration.js';
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;
@@ -176,11 +177,23 @@ function formatClass(cls, compact, methodOffset) {
176
177
  if (cls.isAbstract)
177
178
  modifiers.push('abstract');
178
179
  const modStr = modifiers.length > 0 ? ` (${modifiers.join(', ')})` : '';
180
+ // The bridge sends no access modifier of its own, but it does send the
181
+ // declaration verbatim — and that is where the modifier lives, since AxClass
182
+ // XML has no element for it either. Parsed rather than regexed so a modifier
183
+ // named in an attribute or a doc comment cannot be mistaken for the real one.
184
+ const visibility = cls.declaration
185
+ ? parseXppClassHeader(cls.declaration)?.visibility
186
+ : undefined;
179
187
  let out = `# Class: ${cls.name}${modStr}\n\n`;
180
188
  if (cls.extends)
181
189
  out += `**Extends:** ${cls.extends}\n`;
182
190
  if (cls.model)
183
191
  out += `**Model:** ${cls.model}\n`;
192
+ // Printed on every path, so a reader comparing two classes is not left to infer
193
+ // package scope from a line's absence. Silent only when there was no
194
+ // declaration to read at all (#902).
195
+ if (cls.declaration)
196
+ out += `**Access:** ${visibility ?? 'public'}\n`;
184
197
  out += `**Abstract:** ${cls.isAbstract ? 'Yes' : 'No'}\n`;
185
198
  out += `**Final:** ${cls.isFinal ? 'Yes' : 'No'}\n`;
186
199
  out += `_Source: C# bridge (IMetadataProvider)_\n\n`;
@@ -203,13 +216,35 @@ function formatClass(cls, compact, methodOffset) {
203
216
  out += `### ${m.name}\n\n`;
204
217
  if (m.source) {
205
218
  const preview = m.source.substring(0, 500);
206
- 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`;
207
223
  }
208
224
  }
209
225
  }
210
226
  if (hasMore) {
211
227
  out += `> ⚠️ **${total - methodOffset - CLASS_METHOD_PAGE_SIZE} more methods.** Call again with \`methodOffset: ${methodOffset + CLASS_METHOD_PAGE_SIZE}\`.\n\n`;
212
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
+ }
213
248
  return out;
214
249
  }
215
250
  // METHOD SOURCE
@@ -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,51 @@ interface CheckResult {
4
4
  message: string;
5
5
  fix?: string;
6
6
  }
7
+ /** Classic AOSService VM, or Unified Developer Experience. */
8
+ export type EnvKind = 'traditional' | 'ude';
9
+ /**
10
+ * What a path setting IS, so that "this folder is missing" can name the right
11
+ * cause and the right cure for it.
12
+ *
13
+ * Without this the three settings shared one message, and it fitted only one of
14
+ * them: `packagePath` on UDE. It told a traditional VM that its packages root
15
+ * is "normally auto-detected from the active XPP config" (it is not — the drive
16
+ * scan finds it), and it offered `<drive>:\AosService\PackagesLocalDirectory`,
17
+ * a traditional-VM artifact, as the value to give the UDE ModelStoreFolder and
18
+ * FrameworkDirectory.
19
+ *
20
+ * `autoSource` is the load-bearing one: a stale pin is only a *stale pin* where
21
+ * something would otherwise resolve the value live. Where nothing would, the
22
+ * pin is the configuration, and telling the user to delete it is telling them
23
+ * to break their install — `customPackagesPath` on a traditional VM is exactly
24
+ * that case, the documented fix for junction layouts (docs/MCP_CONFIG.md).
25
+ */
26
+ interface PathFacts {
27
+ humanName: string;
28
+ /** How the value resolves when nothing pins it, or null when nothing does. */
29
+ autoSource(kind: EnvKind): string | null;
30
+ /** Values doctor can actually propose — empty when it has no way to know one. */
31
+ candidates(kind: EnvKind): string[];
32
+ /** What the setting ought to point at, in words. */
33
+ target(kind: EnvKind): string;
34
+ /** Extra context line when there is no candidate to name, or null. */
35
+ note(kind: EnvKind): string | null;
36
+ }
37
+ export declare const PATH_FACTS: Record<string, PathFacts>;
38
+ /**
39
+ * "Path setting points at a folder that isn't there" — with the cause named.
40
+ *
41
+ * A value pinned by the legacy .env instead of the JSON config is the likely bug
42
+ * whenever something else would have resolved it live: the .env copy goes stale
43
+ * the moment a platform update moves the folder, and it keeps outranking the
44
+ * now-correct detection at every startup (see configManager's envContext, which
45
+ * reads these env vars before consulting the XPP config). The server then dies
46
+ * with "C# bridge unavailable (ude)" and nothing points at the .env.
47
+ *
48
+ * Pure — every input is passed in — so the messages can be tested without a
49
+ * Windows box, an XPP config or a real .env.
50
+ */
51
+ export declare function missingPathFix(setting: import('../../config/settings.js').Setting, facts: PathFacts, label: string, configured: string, kind: EnvKind, pinnedByEnv: boolean): Omit<CheckResult, 'severity'>;
7
52
  /**
8
53
  * The configured prefix against the one the model's own objects use.
9
54
  *
@@ -11,8 +56,14 @@ interface CheckResult {
11
56
  * for every model — but the server resolves the model's own naming ABOVE the
12
57
  * configuration, so a user reading only their config has the wrong answer. State
13
58
  * both, and how to pin the configured one.
59
+ *
60
+ * `pinned` is naming.prefixSource=config. This check used to call the inference
61
+ * directly and so never saw it — modelPrefixInference reads it in
62
+ * getInferredModelPrefix, one level above inferPrefixFromObjectNames — which
63
+ * meant a user who had already pinned the prefix was still told their model's
64
+ * naming wins (it does not) and offered the fix they had already applied (#893).
14
65
  */
15
- export declare function checkPrefixResolution(configuredPrefix: string, modelName: string | null, modelObjectNames: string[], label: string): CheckResult[];
66
+ export declare function checkPrefixResolution(configuredPrefix: string, modelName: string | null, modelObjectNames: string[], label: string, pinned?: boolean): CheckResult[];
16
67
  export declare function doctorCommand(): Promise<void>;
17
68
  export {};
18
69
  //# sourceMappingURL=doctor.d.ts.map