fhirsmith 0.16.1 → 0.17.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.
- package/CHANGELOG.md +47 -0
- package/README.md +26 -18
- package/config-template.json +25 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/library/html-server.js +16 -16
- package/library/html.js +9 -0
- package/library/narrative-check.js +203 -0
- package/package.json +2 -1
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +4 -62
- package/packages/utilities/dedupe-versions.js +22 -8
- package/registry/crawler.js +8 -1
- package/registry/registry.js +18 -11
- package/release.sh +80 -0
- package/testing/readme.md +1 -1
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-cs.js +86 -18
- package/tx/cs/cs-loinc.js +32 -14
- package/tx/library.js +9 -2
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/problems.js +7 -5
- package/tx/provider.js +5 -1
- package/tx/tx-html.js +22 -7
- package/tx/tx.js +4 -3
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/workers/expand.js +1 -1
- package/tx/workers/metadata.js +6 -1
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +88 -12
- package/gr.sh +0 -3
package/tx/ocl/README.md
CHANGED
|
@@ -39,14 +39,70 @@ In FHIRsmith terms, these providers are loaded by `tx/library.js` when a source
|
|
|
39
39
|
## Runtime flow
|
|
40
40
|
### Metadata discovery
|
|
41
41
|
CodeSystems (`cs-ocl.js`):
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
- fallback
|
|
42
|
+
- prefer one paginated crawl of the global `/sources/` listing (filtered to
|
|
43
|
+
organization-owned entries)
|
|
44
|
+
- fallback: discover orgs via `/orgs/`, then `/orgs/{org}/sources/` per org
|
|
45
45
|
|
|
46
46
|
ValueSets (`vs-ocl.js`):
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
- fallback
|
|
47
|
+
- prefer one paginated crawl of the global `/collections/` listing (filtered to
|
|
48
|
+
organization-owned entries)
|
|
49
|
+
- fallback: discover orgs via `/orgs/`, then `/orgs/{org}/collections/` per org
|
|
50
|
+
|
|
51
|
+
### Visibility policy (org-only + public-only)
|
|
52
|
+
An artifact is expected to live in an **organization** to be visible through the
|
|
53
|
+
terminology service. User-owned repos (`/users/{user}/...`) are experimental by
|
|
54
|
+
convention and are excluded from both discovery and `$resolveReference` results
|
|
55
|
+
(a canonical resolving to a user-owned repo is treated as unresolved).
|
|
56
|
+
|
|
57
|
+
In addition, a repo is only served when it is **publicly viewable**
|
|
58
|
+
(`public_access` is `View`/`Edit`; `None`/unknown is treated as not servable,
|
|
59
|
+
fail-closed). Because the configured token can see every repo its OCL account has
|
|
60
|
+
access to — and both `$resolveReference` and discovery answer anonymous terminology
|
|
61
|
+
requests — without this gate a *private* repo in the token's org could be surfaced
|
|
62
|
+
publicly. This gate is applied in **both** paths:
|
|
63
|
+
|
|
64
|
+
- **`$resolveReference`**: a resolved repo that is not public is treated as unresolved.
|
|
65
|
+
- **Discovery**: the global (`/sources/`, `/collections/`) and per-org listing crawls
|
|
66
|
+
drop non-public (and non-org-owned) repos before they are registered.
|
|
67
|
+
|
|
68
|
+
`public_access` is not returned by the listing endpoints or by `$resolveReference`
|
|
69
|
+
today, so a shared `PublicAccessChecker` fetches each repo once to read it, caching
|
|
70
|
+
the verdict by `repoUrl` (bounded LRU) and running batch checks with a concurrency
|
|
71
|
+
limit. A check that *errors* is not cached, so a transient blip is retried rather
|
|
72
|
+
than pinning a repo as private. The resolver also rejects any `repoUrl` that is not
|
|
73
|
+
a safe same-host relative path, so an unexpected response can never redirect an
|
|
74
|
+
authenticated request off-host.
|
|
75
|
+
|
|
76
|
+
> **Operational guidance (defense-in-depth):** configure the `token=` account with
|
|
77
|
+
> the **least privilege** needed — ideally a service account that can see only
|
|
78
|
+
> public repos. The `public_access` gate enforces public-only serving in code
|
|
79
|
+
> regardless, but a least-privilege token means a misconfiguration can never
|
|
80
|
+
> expose private content.
|
|
81
|
+
|
|
82
|
+
### Canonical resolution via `$resolveReference`
|
|
83
|
+
With a `token=` configured on the `ocl:` source line, the providers resolve
|
|
84
|
+
"which repo holds this canonical URL?" through OCL's
|
|
85
|
+
[`$resolveReference`](https://docs.openconceptlab.org/en/latest/oclapi/apireference/resolveReference.html)
|
|
86
|
+
(global namespace) instead of iterating listings and matching `canonical_url`
|
|
87
|
+
client-side. Used for ConceptMap source-system search, ValueSet lookup by
|
|
88
|
+
canonical, and batched resolution of a collection's compose source canonicals.
|
|
89
|
+
|
|
90
|
+
Without a token nothing changes: `$resolveReference` is authenticated on every
|
|
91
|
+
instance probed (while the listing endpoints are public), so the resolver is
|
|
92
|
+
constructed disabled and every caller keeps its previous search path. A `404`
|
|
93
|
+
(endpoint not implemented) disables it for the process; a transient `401`/`403`
|
|
94
|
+
backs it off for a cooldown and then retries. The resolution cache is a bounded
|
|
95
|
+
LRU and over-long references are never sent to OCL.
|
|
96
|
+
|
|
97
|
+
### CodeSystem default versions (release vs HEAD)
|
|
98
|
+
OCL's own resolution treats a source's **latest release** as its default version
|
|
99
|
+
(HEAD only when nothing is released), but discovery listings only ever report
|
|
100
|
+
HEAD. With a token, discovery batch-resolves every canonical through
|
|
101
|
+
`$resolveReference` and registers **both** versions: the release becomes what a
|
|
102
|
+
versionless request gets (matching OCL), and HEAD stays reachable via an
|
|
103
|
+
explicit `version=HEAD`. Only canonicals that are new or changed are re-resolved
|
|
104
|
+
on refresh, so a steady-state cycle costs no extra requests. Without a token,
|
|
105
|
+
discovery stays HEAD-only as before.
|
|
50
106
|
|
|
51
107
|
ConceptMaps (`cm-ocl.js`):
|
|
52
108
|
- fetch by id via `/mappings/{id}/`
|
package/tx/ocl/cm-ocl.cjs
CHANGED
|
@@ -4,9 +4,17 @@ const { PAGE_SIZE } = require('./shared/constants');
|
|
|
4
4
|
const { createOclHttpClient } = require('./http/client');
|
|
5
5
|
const { fetchAllPages, extractItemsAndNext } = require('./http/pagination');
|
|
6
6
|
const { stripIdPrefix } = require('../library/resource-ids');
|
|
7
|
+
const { OclReferenceResolver, isOclRepoPath } = require('./resolve/reference-resolver');
|
|
8
|
+
const Logger = require('../../library/logger');
|
|
9
|
+
const oclCmLog = Logger.getInstance().child({ module: 'ocl-cm' });
|
|
7
10
|
|
|
8
11
|
const DEFAULT_MAX_SEARCH_PAGES = 10;
|
|
9
12
|
|
|
13
|
+
// OCL map types that relate concepts without mapping one to the other, so they must
|
|
14
|
+
// not surface as $translate matches: CIEL uses Q-AND-A (question -> answer) and
|
|
15
|
+
// CONCEPT-SET (set -> member). Unknown types still map to related-to.
|
|
16
|
+
const NON_TRANSLATION_MAP_TYPES = new Set(['Q-AND-A', 'CONCEPT-SET']);
|
|
17
|
+
|
|
10
18
|
class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
11
19
|
constructor(config = {}) {
|
|
12
20
|
super();
|
|
@@ -23,6 +31,15 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
23
31
|
this._sourceCandidatesCache = new Map();
|
|
24
32
|
this._sourceUrlsByCanonical = new Map();
|
|
25
33
|
this._canonicalBySourceUrl = new Map();
|
|
34
|
+
|
|
35
|
+
// Asks OCL which repo holds a canonical instead of guessing via text search.
|
|
36
|
+
// Disabled unless a token is configured, in which case #candidateSourceUrls
|
|
37
|
+
// keeps using its existing search.
|
|
38
|
+
this.referenceResolver = new OclReferenceResolver({
|
|
39
|
+
httpClient: this.httpClient,
|
|
40
|
+
token: options.token || null,
|
|
41
|
+
logger: oclCmLog
|
|
42
|
+
});
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
prefixIds() {
|
|
@@ -114,8 +131,12 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
114
131
|
async searchConceptMaps(searchParams, _elements) {
|
|
115
132
|
this._validateSearchParams(searchParams);
|
|
116
133
|
|
|
134
|
+
// Keep the canonical exactly as supplied. Lower-casing it was harmless while
|
|
135
|
+
// every consumer compared through #norm() (which lower-cases anyway), but
|
|
136
|
+
// $resolveReference matches the canonical exactly, and OCL has no
|
|
137
|
+
// .../fhir/codesystem/x -- only .../fhir/CodeSystem/X.
|
|
117
138
|
const params = Object.fromEntries(
|
|
118
|
-
searchParams.map(({ name, value }) => [name, String(value)
|
|
139
|
+
searchParams.map(({ name, value }) => [name, String(value)])
|
|
119
140
|
);
|
|
120
141
|
const sourceSystem = params['source-system'] || params.source || null;
|
|
121
142
|
const targetSystem = params['target-system'] || params.target || null;
|
|
@@ -138,7 +159,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
138
159
|
async #collectMappingsForSearch(sourceSystem, targetSystem) {
|
|
139
160
|
const systemUrl = sourceSystem || targetSystem;
|
|
140
161
|
const candidates = await this.#candidateSourceUrls(systemUrl);
|
|
141
|
-
const sourcePaths = candidates.filter(
|
|
162
|
+
const sourcePaths = candidates.filter(isOclRepoPath);
|
|
142
163
|
|
|
143
164
|
if (sourcePaths.length === 0) {
|
|
144
165
|
return [];
|
|
@@ -147,30 +168,20 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
147
168
|
const allMappings = [];
|
|
148
169
|
for (const sourcePath of sourcePaths) {
|
|
149
170
|
const normalizedPath = this.#normalizeSourcePath(sourcePath);
|
|
150
|
-
let concepts;
|
|
151
171
|
try {
|
|
152
|
-
concepts
|
|
153
|
-
|
|
172
|
+
// Ask the source for its mappings directly. Walking concepts and asking
|
|
173
|
+
// each one costs a request per concept and, worse, only ever sees the
|
|
174
|
+
// first maxSearchPages of them -- for LOINC that is 1000 of 184683
|
|
175
|
+
// concepts (0.5%), so any mapping past that point was silently invisible.
|
|
176
|
+
// Verified live: both paths return the identical mapping set.
|
|
177
|
+
const mappings = await this.#fetchAllPages(
|
|
178
|
+
`${normalizedPath}mappings/`, { limit: PAGE_SIZE }, this.maxSearchPages
|
|
154
179
|
);
|
|
180
|
+
allMappings.push(...mappings);
|
|
155
181
|
} catch (_err) {
|
|
182
|
+
// source exposes no mappings endpoint or is inaccessible — skip
|
|
156
183
|
continue;
|
|
157
184
|
}
|
|
158
|
-
|
|
159
|
-
for (const concept of concepts) {
|
|
160
|
-
const code = concept.id || concept.mnemonic;
|
|
161
|
-
if (!code) {
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
const mappings = await this.#fetchAllPages(
|
|
166
|
-
`${normalizedPath}concepts/${encodeURIComponent(code)}/mappings/`,
|
|
167
|
-
{ limit: PAGE_SIZE }, 2
|
|
168
|
-
);
|
|
169
|
-
allMappings.push(...mappings);
|
|
170
|
-
} catch (_err) {
|
|
171
|
-
// concept has no mappings or endpoint inaccessible — skip
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
const sourceUrlsToResolve = new Set();
|
|
@@ -194,7 +205,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
194
205
|
const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
|
|
195
206
|
const targetCode = mapping.to_concept_code || mapping.toConceptCode;
|
|
196
207
|
|
|
197
|
-
if (!fromSource || !toSource || !sourceCode || !targetCode) {
|
|
208
|
+
if (!fromSource || !toSource || !sourceCode || !targetCode || !this.#isTranslation(mapping)) {
|
|
198
209
|
continue;
|
|
199
210
|
}
|
|
200
211
|
|
|
@@ -252,8 +263,8 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
252
263
|
name: id,
|
|
253
264
|
title: `${sourceId} to ${targetId}`,
|
|
254
265
|
status: 'active',
|
|
255
|
-
|
|
256
|
-
|
|
266
|
+
// No sourceScope/targetScope: a scope is a ValueSet, and $translate resolves it and
|
|
267
|
+
// checks the code against it. The systems are carried by the group instead.
|
|
257
268
|
group: [{
|
|
258
269
|
source: group.sourceCanonical,
|
|
259
270
|
target: group.targetCanonical,
|
|
@@ -290,7 +301,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
290
301
|
const targetCandidates = await this.#candidateSourceUrls(targetSystem);
|
|
291
302
|
|
|
292
303
|
const mappings = [];
|
|
293
|
-
const sourcePaths = sourceCandidates.filter(
|
|
304
|
+
const sourcePaths = sourceCandidates.filter(isOclRepoPath);
|
|
294
305
|
|
|
295
306
|
if (sourceCode && sourcePaths.length > 0) {
|
|
296
307
|
for (const sourcePath of sourcePaths) {
|
|
@@ -410,7 +421,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
410
421
|
const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
|
|
411
422
|
const targetCode = mapping.to_concept_code || mapping.toConceptCode;
|
|
412
423
|
|
|
413
|
-
if (!source || !target || !sourceCode || !targetCode) {
|
|
424
|
+
if (!source || !target || !sourceCode || !targetCode || !this.#isTranslation(mapping)) {
|
|
414
425
|
return null;
|
|
415
426
|
}
|
|
416
427
|
|
|
@@ -430,8 +441,10 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
430
441
|
name: `mapping-${id}`,
|
|
431
442
|
title: mapping.name || `Mapping ${id}`,
|
|
432
443
|
status: 'active',
|
|
433
|
-
|
|
434
|
-
|
|
444
|
+
// No sourceScope/targetScope: a scope is a ValueSet canonical, which $translate resolves
|
|
445
|
+
// and checks the code against. OCL only offers repo paths here (from_source_url or
|
|
446
|
+
// from_collection_url), which resolve to no ValueSet and made every translation fail
|
|
447
|
+
// with not-found. The systems are carried by the group instead.
|
|
435
448
|
group: [
|
|
436
449
|
{
|
|
437
450
|
source: sourceCanonical,
|
|
@@ -461,21 +474,32 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
461
474
|
return new ConceptMap(json, 'R5');
|
|
462
475
|
}
|
|
463
476
|
|
|
477
|
+
// OCL map_type expresses "from concept <relation> to concept", which lines up
|
|
478
|
+
// with the R5 ConceptMapRelationship codes (source <relation> target).
|
|
464
479
|
#toRelationship(mapType) {
|
|
465
|
-
switch ((mapType
|
|
480
|
+
switch (this.#normalizeMapType(mapType)) {
|
|
466
481
|
case 'SAME-AS':
|
|
467
482
|
return 'equivalent';
|
|
468
483
|
case 'NARROWER-THAN':
|
|
469
|
-
return 'narrower-than';
|
|
484
|
+
return 'source-is-narrower-than-target';
|
|
470
485
|
case 'BROADER-THAN':
|
|
471
|
-
return 'broader-than';
|
|
486
|
+
return 'source-is-broader-than-target';
|
|
472
487
|
case 'NOT-EQUIVALENT':
|
|
488
|
+
case 'NOT-SAME-AS':
|
|
473
489
|
return 'not-related-to';
|
|
474
490
|
default:
|
|
475
491
|
return 'related-to';
|
|
476
492
|
}
|
|
477
493
|
}
|
|
478
494
|
|
|
495
|
+
#isTranslation(mapping) {
|
|
496
|
+
return !NON_TRANSLATION_MAP_TYPES.has(this.#normalizeMapType(mapping.map_type || mapping.mapType));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#normalizeMapType(mapType) {
|
|
500
|
+
return String(mapType || '').trim().toUpperCase().replace(/[\s_]+/g, '-');
|
|
501
|
+
}
|
|
502
|
+
|
|
479
503
|
async #searchMappings(params = {}, maxPages = this.maxSearchPages) {
|
|
480
504
|
const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/mappings/` : '/mappings/';
|
|
481
505
|
return await this.#fetchAllPages(endpoint, params, maxPages);
|
|
@@ -523,9 +547,16 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
523
547
|
}
|
|
524
548
|
}
|
|
525
549
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
550
|
+
// Ask OCL which repo holds this canonical. An authoritative answer makes the
|
|
551
|
+
// heuristic text search below unnecessary; without one we fall back to it.
|
|
552
|
+
const resolved = await this.#resolveViaReferenceResolver(systemUrl);
|
|
553
|
+
if (resolved) {
|
|
554
|
+
result.add(resolved);
|
|
555
|
+
} else {
|
|
556
|
+
const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl);
|
|
557
|
+
for (const item of discovered) {
|
|
558
|
+
result.add(item);
|
|
559
|
+
}
|
|
529
560
|
}
|
|
530
561
|
|
|
531
562
|
const out = Array.from(result);
|
|
@@ -533,6 +564,56 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
533
564
|
return out;
|
|
534
565
|
}
|
|
535
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Resolve a canonical to its OCL repo via $resolveReference.
|
|
569
|
+
* Returns null when the resolver is disabled (no token), the endpoint is
|
|
570
|
+
* unavailable, or OCL cannot resolve the canonical -- in every case the caller
|
|
571
|
+
* falls back to the existing search.
|
|
572
|
+
*/
|
|
573
|
+
async #resolveViaReferenceResolver(systemUrl) {
|
|
574
|
+
// Say why once, rather than per lookup: for a tokenless deployment this is the
|
|
575
|
+
// expected steady state, not an error.
|
|
576
|
+
if (!this.referenceResolver.isEnabled()) {
|
|
577
|
+
if (!this._resolverDisabledLogged) {
|
|
578
|
+
this._resolverDisabledLogged = true;
|
|
579
|
+
oclCmLog.info(
|
|
580
|
+
`$resolveReference not in use (${this.referenceResolver.disabledReason}); using source search`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
let resolved;
|
|
587
|
+
try {
|
|
588
|
+
resolved = await this.referenceResolver.resolve(systemUrl);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
oclCmLog.warn(`$resolveReference lookup failed for ${systemUrl}: ${error.message}`);
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (!resolved?.resolved || !resolved.repoUrl) {
|
|
595
|
+
oclCmLog.info(`$resolveReference did not resolve ${systemUrl}; falling back to source search`);
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Logged on success too: resolver and search return the same thing, so without
|
|
600
|
+
// this there is no way -- from outside or from the logs -- to tell which path ran.
|
|
601
|
+
oclCmLog.info(`$resolveReference resolved ${systemUrl} -> ${resolved.repoUrl}`);
|
|
602
|
+
|
|
603
|
+
// Keep the canonical<->repo caches coherent with the search path's bookkeeping.
|
|
604
|
+
// Index under what was asked, but record OCL's own canonical_url as the repo's
|
|
605
|
+
// canonical: echoing the request back would store whatever spelling the caller
|
|
606
|
+
// used rather than the repo's actual identity.
|
|
607
|
+
const canonicalKey = this.#norm(systemUrl);
|
|
608
|
+
if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
|
|
609
|
+
this._sourceUrlsByCanonical.set(canonicalKey, new Set());
|
|
610
|
+
}
|
|
611
|
+
this._sourceUrlsByCanonical.get(canonicalKey).add(resolved.repoUrl);
|
|
612
|
+
this._canonicalBySourceUrl.set(this.#norm(resolved.repoUrl), resolved.canonical || systemUrl);
|
|
613
|
+
|
|
614
|
+
return resolved.repoUrl;
|
|
615
|
+
}
|
|
616
|
+
|
|
536
617
|
async #resolveSourceCandidatesFromOcl(systemUrl) {
|
|
537
618
|
const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/sources/` : '/sources/';
|
|
538
619
|
const query = this.#queryTokenFromSystem(systemUrl);
|
|
@@ -573,17 +654,52 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
573
654
|
}
|
|
574
655
|
|
|
575
656
|
async #ensureCanonicalForSourceUrls(sourceUrls) {
|
|
657
|
+
// Deduplicate the repo paths that still need a canonical.
|
|
658
|
+
const pending = [];
|
|
659
|
+
const seen = new Set();
|
|
576
660
|
for (const sourceUrl of sourceUrls || []) {
|
|
577
661
|
const sourceKey = this.#norm(sourceUrl);
|
|
578
|
-
if (!sourceKey || this._canonicalBySourceUrl.has(sourceKey)) {
|
|
662
|
+
if (!sourceKey || this._canonicalBySourceUrl.has(sourceKey) || seen.has(sourceKey)) {
|
|
579
663
|
continue;
|
|
580
664
|
}
|
|
581
|
-
|
|
582
665
|
const sourcePath = String(sourceUrl || '').trim();
|
|
583
|
-
if (!sourcePath
|
|
666
|
+
if (!isOclRepoPath(sourcePath)) {
|
|
584
667
|
continue;
|
|
585
668
|
}
|
|
669
|
+
seen.add(sourceKey);
|
|
670
|
+
pending.push(sourcePath);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (pending.length === 0) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// One $resolveReference batch instead of one GET per source: the result
|
|
678
|
+
// carries the repo's canonical_url. Anything the batch cannot resolve falls
|
|
679
|
+
// through to the per-source GETs below, so behaviour without a token (or on
|
|
680
|
+
// failure) is unchanged.
|
|
681
|
+
if (this.referenceResolver.isEnabled()) {
|
|
682
|
+
try {
|
|
683
|
+
const results = await this.referenceResolver.resolveReferences(pending);
|
|
684
|
+
if (Array.isArray(results)) {
|
|
685
|
+
for (let i = 0; i < pending.length; i++) {
|
|
686
|
+
const r = results[i];
|
|
687
|
+
if (!r?.resolved || !r.canonical) {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const resolvedSourceUrl = r.repoUrl || pending[i];
|
|
691
|
+
this.#recordCanonicalForSource(pending[i], resolvedSourceUrl, r.canonical);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch (error) {
|
|
695
|
+
oclCmLog.warn(`$resolveReference batch for source canonicals failed: ${error.message}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
586
698
|
|
|
699
|
+
for (const sourcePath of pending) {
|
|
700
|
+
if (this._canonicalBySourceUrl.has(this.#norm(sourcePath))) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
587
703
|
try {
|
|
588
704
|
const response = await this.httpClient.get(sourcePath);
|
|
589
705
|
const source = response.data || {};
|
|
@@ -592,13 +708,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
592
708
|
if (!canonical) {
|
|
593
709
|
continue;
|
|
594
710
|
}
|
|
595
|
-
|
|
596
|
-
const canonicalKey = this.#norm(canonical);
|
|
597
|
-
if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
|
|
598
|
-
this._sourceUrlsByCanonical.set(canonicalKey, new Set());
|
|
599
|
-
}
|
|
600
|
-
this._sourceUrlsByCanonical.get(canonicalKey).add(resolvedSourceUrl);
|
|
601
|
-
this._canonicalBySourceUrl.set(this.#norm(resolvedSourceUrl), canonical);
|
|
711
|
+
this.#recordCanonicalForSource(sourcePath, resolvedSourceUrl, canonical);
|
|
602
712
|
} catch (e) {
|
|
603
713
|
// Ignore source lookup failures and continue resolving remaining sources.
|
|
604
714
|
continue;
|
|
@@ -606,6 +716,18 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
606
716
|
}
|
|
607
717
|
}
|
|
608
718
|
|
|
719
|
+
#recordCanonicalForSource(requestedPath, resolvedSourceUrl, canonical) {
|
|
720
|
+
const canonicalKey = this.#norm(canonical);
|
|
721
|
+
if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
|
|
722
|
+
this._sourceUrlsByCanonical.set(canonicalKey, new Set());
|
|
723
|
+
}
|
|
724
|
+
this._sourceUrlsByCanonical.get(canonicalKey).add(resolvedSourceUrl);
|
|
725
|
+
this._canonicalBySourceUrl.set(this.#norm(resolvedSourceUrl), canonical);
|
|
726
|
+
// Also index under the exact path we were asked about, so the "already
|
|
727
|
+
// resolved" checks hit even when OCL's repo url differs in shape.
|
|
728
|
+
this._canonicalBySourceUrl.set(this.#norm(requestedPath), canonical);
|
|
729
|
+
}
|
|
730
|
+
|
|
609
731
|
#queryTokenFromSystem(systemUrl) {
|
|
610
732
|
const raw = String(systemUrl || '').trim().replace(/\/+$/, '');
|
|
611
733
|
if (!raw) {
|