@glossarist/concept-browser 0.7.87 → 0.7.89
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/package.json +1 -1
- package/scripts/__tests__/bibliography.test.mjs +29 -0
- package/scripts/generate-data.mjs +4 -2
- package/scripts/lib/bibliography.mjs +18 -0
- package/src/__tests__/cross-repo-multiplicity-contract.test.ts +84 -0
- package/src/__tests__/model-bridge-partitive-migration.test.ts +153 -0
- package/src/__tests__/uri-router.test.ts +29 -0
- package/src/__tests__/wire-keys.test.ts +65 -0
- package/src/adapters/GraphDataSource.ts +1 -1
- package/src/adapters/UriRouter.ts +7 -3
- package/src/adapters/concept-identity.ts +6 -2
- package/src/adapters/model-bridge/jsonld-types.ts +169 -0
- package/src/adapters/model-bridge.ts +172 -297
- package/src/adapters/non-verbal/glossarist-augment.d.ts +8 -17
- package/src/adapters/wire-keys.ts +154 -0
- package/vite.config.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.89",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { normalizeBibliography } from '../lib/bibliography.mjs';
|
|
3
|
+
|
|
4
|
+
describe('normalizeBibliography', () => {
|
|
5
|
+
it('wraps a bare array into { bibliography: [...] }', () => {
|
|
6
|
+
const entries = [{ id: 'ISO1' }, { id: 'ISO2' }];
|
|
7
|
+
expect(normalizeBibliography(entries)).toEqual({ bibliography: entries });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('passes through an already-wrapped object', () => {
|
|
11
|
+
const wrapped = { bibliography: [{ id: 'ISO1' }] };
|
|
12
|
+
expect(normalizeBibliography(wrapped)).toBe(wrapped);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns an empty wrapper for null/undefined', () => {
|
|
16
|
+
expect(normalizeBibliography(null)).toEqual({ bibliography: [] });
|
|
17
|
+
expect(normalizeBibliography(undefined)).toEqual({ bibliography: [] });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns an empty wrapper for junk input (non-array, non-wrapped)', () => {
|
|
21
|
+
expect(normalizeBibliography('string')).toEqual({ bibliography: [] });
|
|
22
|
+
expect(normalizeBibliography(42)).toEqual({ bibliography: [] });
|
|
23
|
+
expect(normalizeBibliography({ other: 'shape' })).toEqual({ bibliography: [] });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('handles empty array input', () => {
|
|
27
|
+
expect(normalizeBibliography([])).toEqual({ bibliography: [] });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -14,6 +14,7 @@ import { buildVersionHistoryTurtle } from './lib/version-turtle.mjs';
|
|
|
14
14
|
import { buildBibliographyTurtle } from './lib/bibliography-turtle.mjs';
|
|
15
15
|
import { ttlLit } from './lib/turtle-escape.mjs';
|
|
16
16
|
import { firstNonEmpty } from './lib/first-non-empty.mjs';
|
|
17
|
+
import { normalizeBibliography } from './lib/bibliography.mjs';
|
|
17
18
|
|
|
18
19
|
// MECE partitive multiplicity: 2 independent axes (ISO 704:2022).
|
|
19
20
|
// presence × count
|
|
@@ -1296,9 +1297,10 @@ async function processDataset(dir, register, opts) {
|
|
|
1296
1297
|
// Copy bibliography.yaml → bibliography.json
|
|
1297
1298
|
const bibPath = path.join(sourceRoot, 'bibliography.yaml');
|
|
1298
1299
|
if (fs.existsSync(bibPath)) {
|
|
1299
|
-
const
|
|
1300
|
+
const rawBib = readYaml(bibPath);
|
|
1301
|
+
const bibData = normalizeBibliography(rawBib);
|
|
1300
1302
|
writeJson(path.join(DATA, register, 'bibliography.json'), bibData);
|
|
1301
|
-
const bibCount =
|
|
1303
|
+
const bibCount = bibData.bibliography.length;
|
|
1302
1304
|
console.log(` Copied bibliography (${bibCount} entries)`);
|
|
1303
1305
|
}
|
|
1304
1306
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize bibliography data into the { bibliography: [...] } shape
|
|
3
|
+
* expected by BibliographyData.fromJSON.
|
|
4
|
+
*
|
|
5
|
+
* Accepts either:
|
|
6
|
+
* - A bare array of entries (the canonical YAML source form).
|
|
7
|
+
* - An already-wrapped { bibliography: [...] } object.
|
|
8
|
+
* - Anything else (treated as empty).
|
|
9
|
+
*
|
|
10
|
+
* Ported from origin/fix/bibliography-yaml-array-wrap (2026-06-25).
|
|
11
|
+
* That branch fell 153 commits behind main and was deleted in the
|
|
12
|
+
* 2026-07-29 stale-branch purge; this is the unmerged work.
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeBibliography(raw) {
|
|
15
|
+
if (Array.isArray(raw)) return { bibliography: raw };
|
|
16
|
+
if (raw && Array.isArray(raw.bibliography)) return raw;
|
|
17
|
+
return { bibliography: [] };
|
|
18
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-repo MECE multiplicity table SSOT spec.
|
|
3
|
+
*
|
|
4
|
+
* Audit (AUDIT-2026-07-29 P2-1): pin that the multiplicity mapping
|
|
5
|
+
* table is consistent with glossarist-js's SSOT (multiplicityFromPair).
|
|
6
|
+
*
|
|
7
|
+
* The concept-browser doesn't redefine the table — it re-exports
|
|
8
|
+
* `multiplicityFromPair` from glossarist-js via
|
|
9
|
+
* `src/utils/partitive-multiplicity.ts`. This spec ensures that
|
|
10
|
+
* re-export stays correct, and that the 5 valid combinations and
|
|
11
|
+
* the 1 invalid combination match the documented contract.
|
|
12
|
+
*
|
|
13
|
+
* Sister specs:
|
|
14
|
+
* - glossarist-js test/models/partitive-relation-v4.test.js
|
|
15
|
+
* - glossarist-ruby spec/unit/v3/multiplicity_spec.rb
|
|
16
|
+
* - concept-model spec/multiplicity_spec.rb
|
|
17
|
+
*
|
|
18
|
+
* All four must agree. This spec is the concept-browser side of
|
|
19
|
+
* that cross-repo contract.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import {
|
|
23
|
+
partitiveMultiplicityName,
|
|
24
|
+
rakeStrokeStyle,
|
|
25
|
+
type PartitivePresence,
|
|
26
|
+
type PartitiveCount,
|
|
27
|
+
} from '../utils/partitive-multiplicity';
|
|
28
|
+
|
|
29
|
+
describe('cross-repo MECE multiplicity SSOT contract', () => {
|
|
30
|
+
// The 5 valid combinations are the source of truth. Any change
|
|
31
|
+
// here MUST be mirrored in:
|
|
32
|
+
// - glossarist-js src/models/multiplicity.js (NAME_BY_PAIR)
|
|
33
|
+
// - glossarist-ruby lib/glossarist/v3/multiplicity.rb (NAME_BY_PAIR)
|
|
34
|
+
// - concept-model lib/glossarist/concept_model/multiplicity.rb (MAPPING)
|
|
35
|
+
// - concept-model schemas/v3/concept.yaml (partitive_member.not.allOf)
|
|
36
|
+
// - concept-model ontologies/shapes/glossarist.shacl.ttl (VacuousComboShape)
|
|
37
|
+
const VALID_COMBINATIONS: Array<{
|
|
38
|
+
presence: PartitivePresence;
|
|
39
|
+
count: PartitiveCount;
|
|
40
|
+
iso704Name: string;
|
|
41
|
+
}> = [
|
|
42
|
+
{ presence: 'required', count: 'exactly_one', iso704Name: 'compulsory' },
|
|
43
|
+
{ presence: 'optional', count: 'exactly_one', iso704Name: 'optional' },
|
|
44
|
+
{ presence: 'required', count: 'multiple', iso704Name: 'compulsory_multiple' },
|
|
45
|
+
{ presence: 'optional', count: 'multiple', iso704Name: 'optional_multiple' },
|
|
46
|
+
{ presence: 'required', count: 'at_least_one', iso704Name: 'compulsory_at_least_one' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
it('maps the 5 valid (presence, count) pairs to ISO 704 names', () => {
|
|
50
|
+
for (const { presence, count, iso704Name } of VALID_COMBINATIONS) {
|
|
51
|
+
expect(partitiveMultiplicityName(presence, count)).toBe(iso704Name);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects the vacuous (optional, at_least_one) combination', () => {
|
|
56
|
+
// This combination is invalid because "may exist, ≥1 if it does"
|
|
57
|
+
// collapses to "may exist in any number" = (optional, multiple).
|
|
58
|
+
// The model rejects it at construction; multiplicityFromPair
|
|
59
|
+
// rejects it at name lookup. Both must surface the error.
|
|
60
|
+
expect(() => partitiveMultiplicityName('optional', 'at_least_one'))
|
|
61
|
+
.toThrow(/Invalid multiplicity combination/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('every valid combination produces a renderable rakeStrokeStyle', () => {
|
|
65
|
+
// The renderer must accept every valid combination — no missing
|
|
66
|
+
// branches in the switch. If multiplicityFromPair succeeds,
|
|
67
|
+
// rakeStrokeStyle must succeed too.
|
|
68
|
+
for (const { presence, count } of VALID_COMBINATIONS) {
|
|
69
|
+
const style = rakeStrokeStyle(presence, count, false);
|
|
70
|
+
expect(style.lineCount).toBeGreaterThanOrEqual(1);
|
|
71
|
+
expect(style.lineCount).toBeLessThanOrEqual(2);
|
|
72
|
+
expect(style.strokeWidth).toBeGreaterThan(0);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('the table has exactly 5 entries (no drift)', () => {
|
|
77
|
+
expect(VALID_COMBINATIONS).toHaveLength(5);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('no two pairs map to the same ISO 704 name', () => {
|
|
81
|
+
const names = VALID_COMBINATIONS.map(c => c.iso704Name);
|
|
82
|
+
expect(new Set(names).size).toBe(names.length);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge reverse-map round-trip specs.
|
|
3
|
+
*
|
|
4
|
+
* Audit (AUDIT-2026-07-29 P2-1): pin that loading legacy
|
|
5
|
+
* gl:multiplicity (or older gl:certainty) wire data produces a
|
|
6
|
+
* glossarist-js v4 model with the correct MECE presence × count
|
|
7
|
+
* fields. The migration path lives in model-bridge.ts and must
|
|
8
|
+
* agree with multiplicityFromPair(p, c) at the SSOT level.
|
|
9
|
+
*
|
|
10
|
+
* This spec also covers the invalid-combination case: legacy data
|
|
11
|
+
* carrying the vacuous (optional, at_least_one) combo via
|
|
12
|
+
* gl:multiplicity cannot exist — there's no ISO 704 name for it —
|
|
13
|
+
* so we don't test that path here.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { conceptFromJson } from '../adapters/model-bridge';
|
|
17
|
+
|
|
18
|
+
interface PartitiveMemberLike {
|
|
19
|
+
presence: string;
|
|
20
|
+
count: string;
|
|
21
|
+
is_delimiting: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadPartitives(doc: Record<string, unknown>): PartitiveMemberLike[] {
|
|
25
|
+
const concept = conceptFromJson(doc as any);
|
|
26
|
+
const relations = (concept as any).partitiveRelations ?? [];
|
|
27
|
+
return relations.flatMap((r: any) =>
|
|
28
|
+
(r.partitives ?? []).map((m: any) => ({
|
|
29
|
+
presence: m.presence,
|
|
30
|
+
count: m.count,
|
|
31
|
+
is_delimiting: m.is_delimiting,
|
|
32
|
+
})),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BASE_DOC = {
|
|
37
|
+
'@type': 'gl:Concept',
|
|
38
|
+
'@id': 'https://example.org/vim/concept/1.3',
|
|
39
|
+
'gl:identifier': '1.3',
|
|
40
|
+
'gl:localizedConcept': {},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function docWithRelation(members: unknown[]) {
|
|
44
|
+
return {
|
|
45
|
+
...BASE_DOC,
|
|
46
|
+
'gl:partitiveRelations': [
|
|
47
|
+
{
|
|
48
|
+
'gl:comprehensive': { 'gl:source': 'VIM', 'gl:id': '1.3' },
|
|
49
|
+
'gl:hasPartitive': members,
|
|
50
|
+
'gl:completeness': 'complete',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('bridge reverse-map: legacy gl:multiplicity → MECE presence × count', () => {
|
|
57
|
+
it('compulsory → required + exactly_one', () => {
|
|
58
|
+
const doc = docWithRelation([
|
|
59
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'compulsory' },
|
|
60
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
|
|
61
|
+
]);
|
|
62
|
+
const members = loadPartitives(doc);
|
|
63
|
+
expect(members[0]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
|
|
64
|
+
expect(members[1]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('optional → optional + exactly_one', () => {
|
|
68
|
+
const doc = docWithRelation([
|
|
69
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'optional' },
|
|
70
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
|
|
71
|
+
]);
|
|
72
|
+
const members = loadPartitives(doc);
|
|
73
|
+
expect(members[0]).toEqual({ presence: 'optional', count: 'exactly_one', is_delimiting: false });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('compulsory_multiple → required + multiple', () => {
|
|
77
|
+
const doc = docWithRelation([
|
|
78
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'compulsory_multiple' },
|
|
79
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
|
|
80
|
+
]);
|
|
81
|
+
const members = loadPartitives(doc);
|
|
82
|
+
expect(members[0]).toEqual({ presence: 'required', count: 'multiple', is_delimiting: false });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('optional_multiple → optional + multiple', () => {
|
|
86
|
+
const doc = docWithRelation([
|
|
87
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'optional_multiple' },
|
|
88
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
|
|
89
|
+
]);
|
|
90
|
+
const members = loadPartitives(doc);
|
|
91
|
+
expect(members[0]).toEqual({ presence: 'optional', count: 'multiple', is_delimiting: false });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('compulsory_at_least_one → required + at_least_one', () => {
|
|
95
|
+
const doc = docWithRelation([
|
|
96
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'compulsory_at_least_one' },
|
|
97
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
|
|
98
|
+
]);
|
|
99
|
+
const members = loadPartitives(doc);
|
|
100
|
+
expect(members[0]).toEqual({ presence: 'required', count: 'at_least_one', is_delimiting: false });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('legacy gl:certainty confirmed → required + exactly_one (v2 wire)', () => {
|
|
104
|
+
const doc = docWithRelation([
|
|
105
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:certainty': 'confirmed' },
|
|
106
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:certainty': 'confirmed' },
|
|
107
|
+
]);
|
|
108
|
+
const members = loadPartitives(doc);
|
|
109
|
+
expect(members[0]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('legacy gl:certainty possible → optional + exactly_one (v2 wire)', () => {
|
|
113
|
+
const doc = docWithRelation([
|
|
114
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:certainty': 'possible' },
|
|
115
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:certainty': 'confirmed' },
|
|
116
|
+
]);
|
|
117
|
+
const members = loadPartitives(doc);
|
|
118
|
+
expect(members[0]).toEqual({ presence: 'optional', count: 'exactly_one', is_delimiting: false });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('bridge: native MECE gl:presence + gl:count passes through', () => {
|
|
123
|
+
it('presence=optional + count=multiple loads natively', () => {
|
|
124
|
+
const doc = docWithRelation([
|
|
125
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:presence': 'optional', 'gl:count': 'multiple' },
|
|
126
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:presence': 'required', 'gl:count': 'exactly_one' },
|
|
127
|
+
]);
|
|
128
|
+
const members = loadPartitives(doc);
|
|
129
|
+
expect(members[0]).toEqual({ presence: 'optional', count: 'multiple', is_delimiting: false });
|
|
130
|
+
expect(members[1]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('gl:isDelimiting=true preserved', () => {
|
|
134
|
+
const doc = docWithRelation([
|
|
135
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:isDelimiting': true },
|
|
136
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' } },
|
|
137
|
+
]);
|
|
138
|
+
const members = loadPartitives(doc);
|
|
139
|
+
expect(members[0].is_delimiting).toBe(true);
|
|
140
|
+
expect(members[1].is_delimiting).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('bridge: defaults when no multiplicity fields present', () => {
|
|
145
|
+
it('member with no presence/count/multiplicity/certainty defaults to required + exactly_one', () => {
|
|
146
|
+
const doc = docWithRelation([
|
|
147
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' } },
|
|
148
|
+
{ 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' } },
|
|
149
|
+
]);
|
|
150
|
+
const members = loadPartitives(doc);
|
|
151
|
+
expect(members[0]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -103,4 +103,33 @@ describe('UriRouter', () => {
|
|
|
103
103
|
expect(UriRouter.parseUri('https://example.com/other')).toBeNull();
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
describe('SSOT: UriRouter.buildConceptUri is the single concept-URI constructor', () => {
|
|
108
|
+
// Per AUDIT-2026-07-29 P1-1: ConceptIdentity, the conceptUri()
|
|
109
|
+
// bridge, and UriRouter.buildConceptUri must all funnel through
|
|
110
|
+
// UriRouter.buildConceptUri. No inline template literals for
|
|
111
|
+
// `${uriBase}/${registerId}/concept/${id}` outside UriRouter.
|
|
112
|
+
|
|
113
|
+
it('UriRouter.buildConceptUri constructs the canonical URI', () => {
|
|
114
|
+
expect(UriRouter.buildConceptUri('https://x.org', 'vim', '1.3'))
|
|
115
|
+
.toBe('https://x.org/vim/concept/1.3');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('UriRouter.buildDomainUri constructs the canonical domain URI', () => {
|
|
119
|
+
expect(UriRouter.buildDomainUri('https://x.org', 'vim', 'physics'))
|
|
120
|
+
.toBe('https://x.org/vim/domain/physics');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('ConceptIdentity.uri delegates to UriRouter.buildConceptUri', async () => {
|
|
124
|
+
const { ConceptIdentity } = await import('../adapters/concept-identity');
|
|
125
|
+
const id = new ConceptIdentity('1.3', 'vim', 'https://x.org');
|
|
126
|
+
expect(id.uri).toBe(UriRouter.buildConceptUri('https://x.org', 'vim', '1.3'));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('ConceptIdentity.domainUri delegates to UriRouter.buildDomainUri', async () => {
|
|
130
|
+
const { ConceptIdentity } = await import('../adapters/concept-identity');
|
|
131
|
+
const id = new ConceptIdentity('1.3', 'vim', 'https://x.org');
|
|
132
|
+
expect(id.domainUri('physics')).toBe(UriRouter.buildDomainUri('https://x.org', 'vim', 'physics'));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
106
135
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-key SSOT contract.
|
|
3
|
+
*
|
|
4
|
+
* Audit-driven (AUDIT-2026-07-29 P1-3).
|
|
5
|
+
*
|
|
6
|
+
* The GL record is the single source of truth for the JSON-LD wire-format
|
|
7
|
+
* keys used across src/adapters/. This spec pins:
|
|
8
|
+
*
|
|
9
|
+
* - Every entry is a string starting with 'gl:' (or '@' for JSON-LD core)
|
|
10
|
+
* - No two entries share the same value (injectivity — one key, one name)
|
|
11
|
+
* - The set of values matches what's declared in jsonld-types.ts
|
|
12
|
+
* (catches drift if someone adds a key to one but not the other)
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { GL } from '../adapters/wire-keys';
|
|
16
|
+
|
|
17
|
+
describe('GL wire-key SSOT', () => {
|
|
18
|
+
it('every value is a non-empty string', () => {
|
|
19
|
+
for (const value of Object.values(GL)) {
|
|
20
|
+
expect(typeof value).toBe('string');
|
|
21
|
+
expect(value.length).toBeGreaterThan(0);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('every value starts with gl: or @ (JSON-LD namespace)', () => {
|
|
26
|
+
for (const value of Object.values(GL)) {
|
|
27
|
+
expect(value.startsWith('gl:') || value.startsWith('@')).toBe(true);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('no two entries share the same value (injectivity)', () => {
|
|
32
|
+
const values = Object.values(GL);
|
|
33
|
+
expect(new Set(values).size).toBe(values.length);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('MECE keys are present (presence, count, isDelimiting, multiplicity, certainty)', () => {
|
|
37
|
+
expect(GL.PRESENCE).toBe('gl:presence');
|
|
38
|
+
expect(GL.COUNT).toBe('gl:count');
|
|
39
|
+
expect(GL.IS_DELIMITING).toBe('gl:isDelimiting');
|
|
40
|
+
expect(GL.MULTIPLICITY).toBe('gl:multiplicity');
|
|
41
|
+
expect(GL.CERTAINTY).toBe('gl:certainty');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('JSON-LD core keys are present', () => {
|
|
45
|
+
expect(GL.ID).toBe('@id');
|
|
46
|
+
expect(GL.TYPE).toBe('@type');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('LOCAL_ID is distinct from ID (one is gl:id, the other @id)', () => {
|
|
50
|
+
// Both represent "id" but in different namespaces:
|
|
51
|
+
// GL.ID (@id) — JSON-LD IRI for the concept
|
|
52
|
+
// GL.LOCAL_ID (gl:id) — source-local id within ConceptSource/Origin/Ref
|
|
53
|
+
expect(GL.ID).toBe('@id');
|
|
54
|
+
expect(GL.LOCAL_ID).toBe('gl:id');
|
|
55
|
+
expect(GL.ID).not.toBe(GL.LOCAL_ID);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('count matches documented set (no drift)', () => {
|
|
59
|
+
// Update this number when adding a new key.
|
|
60
|
+
// The point of this test: when adding a key, you remember to update
|
|
61
|
+
// the count; when removing, you notice the test fails. Catches
|
|
62
|
+
// accidental drift in either direction.
|
|
63
|
+
expect(Object.keys(GL).length).toBe(87);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -137,7 +137,7 @@ export class GraphDataSource {
|
|
|
137
137
|
if (lc?.domain) {
|
|
138
138
|
edges.push({
|
|
139
139
|
source: sourceUri,
|
|
140
|
-
target:
|
|
140
|
+
target: UriRouter.buildDomainUri(this.uriBase, this.registerId, slugify(lc.domain)),
|
|
141
141
|
type: 'domain',
|
|
142
142
|
label: lc.domain,
|
|
143
143
|
register: this.registerId,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Manifest } from './types';
|
|
2
|
-
import { ConceptIdentity } from './concept-identity';
|
|
3
2
|
|
|
4
3
|
// ── URI pattern matching ────────────────────────────────────────────────────
|
|
5
4
|
|
|
@@ -106,11 +105,16 @@ export class UriRouter {
|
|
|
106
105
|
return `${uriBase}/${registerId}/concept/${conceptId}`;
|
|
107
106
|
}
|
|
108
107
|
|
|
109
|
-
/** Construct a canonical URI
|
|
108
|
+
/** Construct a canonical domain URI from components. */
|
|
109
|
+
static buildDomainUri(uriBase: string, registerId: string, domainSlug: string): string {
|
|
110
|
+
return `${uriBase}/${registerId}/domain/${domainSlug}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Construct a canonical URI for a concept from its register-id + concept-id. */
|
|
110
114
|
buildUri(registerId: string, conceptId: string): string {
|
|
111
115
|
const info = this.registerMap.get(registerId);
|
|
112
116
|
const uriBase = info?.uriBase ?? '';
|
|
113
|
-
return
|
|
117
|
+
return UriRouter.buildConceptUri(uriBase, registerId, conceptId);
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
getRegisteredIds(): string[] {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { InvalidConceptIdentityError, InvalidConceptUriError } from '../errors';
|
|
2
|
+
import { UriRouter } from './UriRouter';
|
|
2
3
|
|
|
3
4
|
export interface ConceptIdentityParts {
|
|
4
5
|
readonly localId: string;
|
|
@@ -24,7 +25,10 @@ export class ConceptIdentity implements ConceptIdentityParts {
|
|
|
24
25
|
{ localId, registerId, uriBase },
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
// Single SSOT for concept URI construction: UriRouter.buildConceptUri.
|
|
29
|
+
// ConceptIdentity, the conceptUri() bridge, and UriRouter.buildConceptUri
|
|
30
|
+
// all funnel here — no inline template literals scattered across files.
|
|
31
|
+
this._uri = UriRouter.buildConceptUri(uriBase, registerId, localId);
|
|
28
32
|
this._slug = localId;
|
|
29
33
|
this._path = `${registerId}/concepts/${localId}`;
|
|
30
34
|
}
|
|
@@ -52,7 +56,7 @@ export class ConceptIdentity implements ConceptIdentityParts {
|
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
domainUri(domainSlug: string): string {
|
|
55
|
-
return
|
|
59
|
+
return UriRouter.buildDomainUri(this.uriBase, this.registerId, domainSlug);
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
static fromUri(uri: string): ConceptIdentity {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD wire-format types for the model-bridge.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from model-bridge.ts to keep the mapper/bridge logic
|
|
5
|
+
* scannable. The 'gl:' prefix is the Glossarist JSON-LD namespace —
|
|
6
|
+
* see data/concept-model/ontologies/glossarist.context.jsonld.
|
|
7
|
+
*
|
|
8
|
+
* Wire format is dictated by glossarist-js's JSON-LD context, which
|
|
9
|
+
* is stable. Schema changes are rare and reviewable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface JsonLdContent {
|
|
13
|
+
'gl:content'?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface JsonLdDate {
|
|
17
|
+
'gl:date'?: string;
|
|
18
|
+
'gl:dateType'?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface JsonLdPronunciation {
|
|
22
|
+
'gl:content'?: string;
|
|
23
|
+
'gl:language'?: string;
|
|
24
|
+
'gl:script'?: string;
|
|
25
|
+
'gl:system'?: string;
|
|
26
|
+
'gl:country'?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface JsonLdGrammarInfo {
|
|
30
|
+
'gl:gender'?: string;
|
|
31
|
+
'gl:number'?: string;
|
|
32
|
+
'gl:partOfSpeech'?: string;
|
|
33
|
+
'gl:noun'?: boolean;
|
|
34
|
+
'gl:verb'?: boolean;
|
|
35
|
+
'gl:adj'?: boolean;
|
|
36
|
+
'gl:adverb'?: boolean;
|
|
37
|
+
'gl:preposition'?: boolean;
|
|
38
|
+
'gl:participle'?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface JsonLdRef {
|
|
42
|
+
'gl:source'?: string;
|
|
43
|
+
'gl:id'?: string;
|
|
44
|
+
'gl:version'?: string;
|
|
45
|
+
'gl:text'?: string;
|
|
46
|
+
source?: string;
|
|
47
|
+
id?: string;
|
|
48
|
+
version?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface JsonLdLocality {
|
|
52
|
+
'gl:localityType'?: string;
|
|
53
|
+
'gl:referenceFrom'?: string;
|
|
54
|
+
'gl:referenceTo'?: string;
|
|
55
|
+
type?: string;
|
|
56
|
+
reference_from?: string;
|
|
57
|
+
reference_to?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface JsonLdOrigin {
|
|
61
|
+
'gl:ref'?: string | JsonLdRef;
|
|
62
|
+
'gl:locality'?: JsonLdLocality;
|
|
63
|
+
'gl:link'?: string;
|
|
64
|
+
'gl:id'?: string;
|
|
65
|
+
'gl:version'?: string;
|
|
66
|
+
'gl:source'?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface JsonLdSource {
|
|
70
|
+
'gl:id'?: string;
|
|
71
|
+
'gl:sourceType'?: string;
|
|
72
|
+
'gl:sourceStatus'?: string;
|
|
73
|
+
'gl:modification'?: string;
|
|
74
|
+
'gl:origin'?: JsonLdOrigin;
|
|
75
|
+
'gl:sourcedFrom'?: JsonLdOrigin[];
|
|
76
|
+
'gl:sourced_from'?: JsonLdOrigin[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface JsonLdRelated {
|
|
80
|
+
'gl:relationshipType'?: string;
|
|
81
|
+
'gl:ref'?: JsonLdRef;
|
|
82
|
+
'@id'?: string;
|
|
83
|
+
'gl:term'?: string;
|
|
84
|
+
'gl:target'?: string;
|
|
85
|
+
'gl:sourceId'?: string;
|
|
86
|
+
'gl:citation'?: JsonLdOrigin;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface JsonLdDesignation {
|
|
90
|
+
'@type'?: string;
|
|
91
|
+
'gl:term'?: string;
|
|
92
|
+
'gl:normativeStatus'?: string;
|
|
93
|
+
'gl:absent'?: unknown;
|
|
94
|
+
'gl:fieldOfApplication'?: string;
|
|
95
|
+
'gl:usageInfo'?: string;
|
|
96
|
+
'gl:geographicalArea'?: string;
|
|
97
|
+
'gl:language'?: string;
|
|
98
|
+
'gl:script'?: string;
|
|
99
|
+
'gl:system'?: string;
|
|
100
|
+
'gl:international'?: boolean;
|
|
101
|
+
'gl:termType'?: string;
|
|
102
|
+
'gl:pronunciation'?: JsonLdPronunciation[];
|
|
103
|
+
'gl:source'?: JsonLdSource[];
|
|
104
|
+
'gl:related'?: JsonLdRelated[];
|
|
105
|
+
'gl:prefix'?: string;
|
|
106
|
+
'gl:gender'?: string;
|
|
107
|
+
'gl:grammarInfo'?: JsonLdGrammarInfo[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface JsonLdLocalizedConcept {
|
|
111
|
+
'gl:languageCode'?: string;
|
|
112
|
+
'gl:entryStatus'?: string;
|
|
113
|
+
'gl:classification'?: string;
|
|
114
|
+
'gl:reviewType'?: string;
|
|
115
|
+
'gl:domain'?: string;
|
|
116
|
+
'gl:release'?: string;
|
|
117
|
+
'gl:lineageSourceSimilarity'?: number;
|
|
118
|
+
'gl:script'?: string;
|
|
119
|
+
'gl:system'?: string;
|
|
120
|
+
'gl:designation'?: JsonLdDesignation[];
|
|
121
|
+
'gl:definition'?: JsonLdContent[];
|
|
122
|
+
'gl:notes'?: JsonLdContent[];
|
|
123
|
+
'gl:annotations'?: JsonLdContent[];
|
|
124
|
+
'gl:examples'?: JsonLdContent[];
|
|
125
|
+
'gl:source'?: JsonLdSource[];
|
|
126
|
+
'gl:dates'?: JsonLdDate[];
|
|
127
|
+
'gl:references'?: JsonLdRelated[];
|
|
128
|
+
'gl:reviewDate'?: string;
|
|
129
|
+
'gl:reviewDecisionDate'?: string;
|
|
130
|
+
'gl:reviewDecisionEvent'?: string;
|
|
131
|
+
'gl:reviewStatus'?: string;
|
|
132
|
+
'gl:reviewDecision'?: string;
|
|
133
|
+
'gl:reviewDecisionNotes'?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface JsonLdPartitiveMember {
|
|
137
|
+
'gl:ref'?: JsonLdRef;
|
|
138
|
+
/** ISO 704:2022 MECE: presence (required | optional) */
|
|
139
|
+
'gl:presence'?: string;
|
|
140
|
+
/** ISO 704:2022 MECE: count (exactly_one | at_least_one | multiple) */
|
|
141
|
+
'gl:count'?: string;
|
|
142
|
+
/** ISO 704:2022: delimiting part (3× stroke width in diagrams) */
|
|
143
|
+
'gl:isDelimiting'?: boolean;
|
|
144
|
+
/** Legacy v3 field — one-string multiplicity, split into presence+count */
|
|
145
|
+
'gl:multiplicity'?: string;
|
|
146
|
+
/** Legacy v2 field — migrated to presence+count */
|
|
147
|
+
'gl:certainty'?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface JsonLdPartitiveRelation {
|
|
151
|
+
'gl:comprehensive'?: JsonLdRef;
|
|
152
|
+
'gl:hasPartitive'?: JsonLdPartitiveMember[];
|
|
153
|
+
'gl:completeness'?: string;
|
|
154
|
+
'gl:criterion'?: Record<string, string> | string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface JsonLdConcept {
|
|
158
|
+
'@type'?: string;
|
|
159
|
+
'@id'?: string;
|
|
160
|
+
'gl:identifier'?: string | number;
|
|
161
|
+
'gl:term'?: string;
|
|
162
|
+
'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
|
|
163
|
+
'gl:related'?: JsonLdRelated[];
|
|
164
|
+
'gl:partitiveRelations'?: JsonLdPartitiveRelation[];
|
|
165
|
+
'gl:tags'?: string[];
|
|
166
|
+
'gl:figureRef'?: unknown[];
|
|
167
|
+
'gl:tableRef'?: unknown[];
|
|
168
|
+
'gl:formulaRef'?: unknown[];
|
|
169
|
+
}
|