@glossarist/concept-browser 0.7.110 → 0.7.111

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.110",
3
+ "version": "0.7.111",
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": {
@@ -38,7 +38,7 @@
38
38
  "astro": "^7.0.7",
39
39
  "d3": "^7.9.0",
40
40
  "favicons": "^7.2.0",
41
- "glossarist": "^0.4.51",
41
+ "glossarist": "^0.4.52",
42
42
  "js-yaml": "^4.1.0",
43
43
  "jszip": "^3.10.1",
44
44
  "pinia": "^2.3.1",
@@ -1,79 +1,155 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import {
3
3
  isExternalConcept,
4
+ isExternalMember,
5
+ isExternalComprehensive,
6
+ getExternalMembers,
4
7
  hasProvidedBy,
5
- isDanglingExternal,
8
+ hasDanglingExternal,
6
9
  formatExternalLabel,
7
- type ConceptStoreLike,
10
+ type ConceptStore,
11
+ type ExternalConceptLike,
8
12
  } from '../../utils/external-detection';
13
+ import { checkExtensionalCompleteness } from 'glossarist';
9
14
 
10
- const mockStore: ConceptStoreLike = {
15
+ /**
16
+ * External-detection + extensional-completeness contract tests.
17
+ *
18
+ * glossarist@0.4.52 promoted these utilities from concept-browser's
19
+ * local src/utils/external-detection.ts into the upstream library.
20
+ * This spec pins:
21
+ * 1. The local module re-exports the upstream functions (no drift).
22
+ * 2. The upstream signatures work as documented.
23
+ * 3. `formatExternalLabel` (the UI-only helper) stays local.
24
+ * 4. `checkExtensionalCompleteness` flags ISO 704:2022 §6.4.5.1
25
+ * open-ended patterns in extensional definitions.
26
+ */
27
+
28
+ const extResolved: ExternalConceptLike = { status: 'external', related: [{ type: 'provided_by' }] };
29
+ const extDangling: ExternalConceptLike = { status: 'external', related: [] };
30
+ const internal: ExternalConceptLike = { status: 'valid', related: [] };
31
+
32
+ const store: ConceptStore = {
11
33
  lookup(ref) {
12
- if (ref?.id === 'ext-1') {
13
- return { status: 'external', relatedConcepts: [{ type: 'provided_by' }] };
14
- }
15
- if (ref?.id === 'ext-2') {
16
- return { status: 'external', relatedConcepts: [] };
17
- }
18
- if (ref?.id === 'int-1') {
19
- return { status: 'valid', relatedConcepts: [] };
20
- }
34
+ if (ref?.id === 'ext-resolved') return extResolved;
35
+ if (ref?.id === 'ext-dangling') return extDangling;
36
+ if (ref?.id === 'int-1') return internal;
21
37
  return null;
22
38
  },
23
39
  };
24
40
 
25
- describe('external-detection', () => {
41
+ describe('external-detection (re-exports from glossarist@0.4.52)', () => {
26
42
  describe('isExternalConcept', () => {
27
- it('returns true when the concept status is external', () => {
28
- expect(isExternalConcept({ source: 's', id: 'ext-1' }, mockStore)).toBe(true);
43
+ it('returns true when concept.status === "external"', () => {
44
+ expect(isExternalConcept(extResolved)).toBe(true);
45
+ expect(isExternalConcept(extDangling)).toBe(true);
29
46
  });
30
47
 
31
- it('returns false when the concept status is valid', () => {
32
- expect(isExternalConcept({ source: 's', id: 'int-1' }, mockStore)).toBe(false);
48
+ it('returns false for internal concepts', () => {
49
+ expect(isExternalConcept(internal)).toBe(false);
33
50
  });
34
51
 
35
- it('returns false when the concept is not found', () => {
36
- expect(isExternalConcept({ source: 's', id: 'unknown' }, mockStore)).toBe(false);
52
+ it('returns false for null/undefined', () => {
53
+ expect(isExternalConcept(null)).toBe(false);
54
+ expect(isExternalConcept(undefined)).toBe(false);
37
55
  });
56
+ });
38
57
 
39
- it('returns false when ref is null', () => {
40
- expect(isExternalConcept(null, mockStore)).toBe(false);
58
+ describe('isExternalMember', () => {
59
+ it('returns true when the member ref resolves to an external concept', () => {
60
+ expect(isExternalMember({ ref: { source: 's', id: 'ext-resolved' } }, store)).toBe(true);
61
+ });
62
+
63
+ it('returns false when the member ref resolves to an internal concept', () => {
64
+ expect(isExternalMember({ ref: { source: 's', id: 'int-1' } }, store)).toBe(false);
41
65
  });
42
66
 
43
67
  it('returns false when store is null', () => {
44
- expect(isExternalConcept({ source: 's', id: 'ext-1' }, null)).toBe(false);
68
+ expect(isExternalMember({ ref: { source: 's', id: 'ext-resolved' } }, null)).toBe(false);
69
+ });
70
+
71
+ it('returns false when ref is missing', () => {
72
+ expect(isExternalMember({}, store)).toBe(false);
73
+ expect(isExternalMember(null, store)).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe('isExternalComprehensive', () => {
78
+ it('returns true when the comprehensive ref resolves to external', () => {
79
+ const hyperedge = {
80
+ comprehensive: { source: 's', id: 'ext-resolved' },
81
+ members: [],
82
+ };
83
+ expect(isExternalComprehensive(hyperedge, store)).toBe(true);
84
+ });
85
+
86
+ it('returns false when the comprehensive ref resolves to internal', () => {
87
+ const hyperedge = {
88
+ comprehensive: { source: 's', id: 'int-1' },
89
+ members: [],
90
+ };
91
+ expect(isExternalComprehensive(hyperedge, store)).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('getExternalMembers', () => {
96
+ it('returns only the members whose refs resolve to external', () => {
97
+ const hyperedge = {
98
+ comprehensive: { source: 's', id: 'int-1' },
99
+ members: [
100
+ { ref: { source: 's', id: 'ext-resolved' } },
101
+ { ref: { source: 's', id: 'int-1' } },
102
+ { ref: { source: 's', id: 'ext-dangling' } },
103
+ ],
104
+ };
105
+ const external = getExternalMembers(hyperedge, store);
106
+ expect(external).toHaveLength(2);
107
+ expect(external.map(m => (m.ref as { id?: string } | undefined)?.id)).toEqual(['ext-resolved', 'ext-dangling']);
45
108
  });
46
109
  });
47
110
 
48
111
  describe('hasProvidedBy', () => {
49
- it('returns true when the external concept has a provided_by edge', () => {
50
- expect(hasProvidedBy({ source: 's', id: 'ext-1' }, mockStore)).toBe(true);
112
+ it('returns true when the concept has a provided_by edge', () => {
113
+ expect(hasProvidedBy(extResolved)).toBe(true);
51
114
  });
52
115
 
53
- it('returns false when the external concept has no provided_by edge', () => {
54
- expect(hasProvidedBy({ source: 's', id: 'ext-2' }, mockStore)).toBe(false);
116
+ it('returns false when the concept has no provided_by edge', () => {
117
+ expect(hasProvidedBy(extDangling)).toBe(false);
55
118
  });
56
119
 
57
- it('returns false when the concept is not found', () => {
58
- expect(hasProvidedBy({ source: 's', id: 'unknown' }, mockStore)).toBe(false);
120
+ it('returns false for null/undefined', () => {
121
+ expect(hasProvidedBy(null)).toBe(false);
122
+ expect(hasProvidedBy(undefined)).toBe(false);
59
123
  });
60
124
  });
61
125
 
62
- describe('isDanglingExternal', () => {
63
- it('returns false when the external concept has provided_by (resolved)', () => {
64
- expect(isDanglingExternal({ source: 's', id: 'ext-1' }, mockStore)).toBe(false);
126
+ describe('hasDanglingExternal (hyperedge-level)', () => {
127
+ it('returns true when any external ref lacks a provided_by edge', () => {
128
+ const hyperedge = {
129
+ comprehensive: { source: 's', id: 'ext-dangling' },
130
+ members: [],
131
+ };
132
+ expect(hasDanglingExternal(hyperedge, store)).toBe(true);
65
133
  });
66
134
 
67
- it('returns true when the external concept has no provided_by (dangling)', () => {
68
- expect(isDanglingExternal({ source: 's', id: 'ext-2' }, mockStore)).toBe(true);
135
+ it('returns false when every external ref is resolved', () => {
136
+ const hyperedge = {
137
+ comprehensive: { source: 's', id: 'ext-resolved' },
138
+ members: [{ ref: { source: 's', id: 'ext-resolved' } }],
139
+ };
140
+ expect(hasDanglingExternal(hyperedge, store)).toBe(false);
69
141
  });
70
142
 
71
- it('returns false for internal concepts', () => {
72
- expect(isDanglingExternal({ source: 's', id: 'int-1' }, mockStore)).toBe(false);
143
+ it('returns false when no refs are external', () => {
144
+ const hyperedge = {
145
+ comprehensive: { source: 's', id: 'int-1' },
146
+ members: [{ ref: { source: 's', id: 'int-1' } }],
147
+ };
148
+ expect(hasDanglingExternal(hyperedge, store)).toBe(false);
73
149
  });
74
150
  });
75
151
 
76
- describe('formatExternalLabel', () => {
152
+ describe('formatExternalLabel (local UI helper, not in upstream)', () => {
77
153
  it('wraps the label in parentheses for external concepts', () => {
78
154
  expect(formatExternalLabel('precision condition', true)).toBe('(precision condition)');
79
155
  });
@@ -83,3 +159,30 @@ describe('external-detection', () => {
83
159
  });
84
160
  });
85
161
  });
162
+
163
+ describe('checkExtensionalCompleteness (ISO 704:2022 §6.4.5.1)', () => {
164
+ it('returns null for non-extensional definitions', () => {
165
+ expect(checkExtensionalCompleteness({ type: 'intensional', content: '...' })).toBeNull();
166
+ expect(checkExtensionalCompleteness({ type: null, content: '...' })).toBeNull();
167
+ expect(checkExtensionalCompleteness(null)).toBeNull();
168
+ });
169
+
170
+ it('returns null for an extensional definition that lists every member', () => {
171
+ expect(checkExtensionalCompleteness({
172
+ type: 'extensional',
173
+ content: '1, 2, 3',
174
+ })).toBeNull();
175
+ });
176
+
177
+ it('returns a warning when an extensional definition uses an open-ended pattern', () => {
178
+ // Open-ended patterns: "etc.", "...", "and so on", "includes but is not limited to"
179
+ const issue = checkExtensionalCompleteness({
180
+ type: 'extensional',
181
+ content: 'length, mass, time, etc.',
182
+ });
183
+ expect(issue).not.toBeNull();
184
+ expect(issue?.severity).toBe('warning');
185
+ expect(issue?.rule).toBe('ISO-704-6.4.5.1');
186
+ expect(issue?.message).toMatch(/completeness|extensional/i);
187
+ });
188
+ });
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Concept, LocalizedConcept, Designation, Expression, Abbreviation as AbbreviationType } from 'glossarist';
3
+ import { checkExtensionalCompleteness, type ExtensionalCompletenessIssue } from 'glossarist';
3
4
  import { computed } from 'vue';
4
5
  import { langName, langLabel } from '../utils/lang';
5
6
  import { renderContent } from '../utils/content-renderer';
@@ -33,6 +34,33 @@ const definition = computed(() => {
33
34
  const content = lc.value.definitions.map(d => d.content).filter(Boolean).join('\n\n');
34
35
  return content;
35
36
  });
37
+
38
+ /**
39
+ * ISO 704:2022 §6.4.5.1 extensional-completeness warnings.
40
+ *
41
+ * An extensional definition enumerates every member of the concept's
42
+ * extension. Open-ended phrasing ("etc.", "...", "and so on") signals
43
+ * that the enumeration is incomplete — the definition is invalid per
44
+ * ISO 704. Surface each warning inline so editors can fix the source.
45
+ *
46
+ * The validator gates on `definition.type === 'extensional'`. Today
47
+ * concept-browser's DetailedDefinition model doesn't carry that field,
48
+ * so this returns [] — the wiring is in place for the moment glossarist
49
+ * exposes definition-type tracking (or for consumers that annotate
50
+ * definitions themselves).
51
+ */
52
+ const extensionalIssues = computed<ExtensionalCompletenessIssue[]>(() => {
53
+ if (!lc.value) return [];
54
+ const issues: ExtensionalCompletenessIssue[] = [];
55
+ for (const d of lc.value.definitions) {
56
+ const issue = checkExtensionalCompleteness(
57
+ d as unknown as { type?: string | null; content?: string | null },
58
+ );
59
+ if (issue) issues.push(issue);
60
+ }
61
+ return issues;
62
+ });
63
+
36
64
  const notes = computed(() => lc.value?.notes.map(n => n.content).filter(Boolean) ?? []);
37
65
  const examples = computed(() => lc.value?.examples.map(e => e.content).filter(Boolean) ?? []);
38
66
  const sources = computed(() => lc.value?.sources ?? []);
@@ -140,6 +168,16 @@ function handleContentClick(e: MouseEvent) {
140
168
  <div v-if="definition" class="card p-5">
141
169
  <div class="section-label">{{ t('concept.definition') }}</div>
142
170
  <div class="text-ink-800 leading-relaxed mt-3" v-html="renderContent(definition, renderOpts)"></div>
171
+ <!-- ISO 704:2022 §6.4.5.1 extensional-completeness warnings -->
172
+ <div
173
+ v-for="(issue, i) in extensionalIssues"
174
+ :key="'ext'+i"
175
+ class="mt-3 p-2 rounded border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-900 dark:text-amber-100"
176
+ :title="`Rule: ${issue.rule}`"
177
+ >
178
+ <span class="font-semibold">⚠ {{ t('concept.extensionalWarning') || 'Extensional completeness' }}:</span>
179
+ {{ issue.message }}
180
+ </div>
143
181
  </div>
144
182
 
145
183
  <!-- Notes -->
@@ -9,6 +9,10 @@
9
9
  * drop's line style encodes its multiplicity + is_delimiting per
10
10
  * ISO 704:2022 (5 multiplicity values × 2 delimiting states).
11
11
  *
12
+ * External concepts (status: 'external') are wrapped in parentheses per
13
+ * ISO 704:2022 §5.5.4.3.1, using the upstream `isExternalMember` /
14
+ * `isExternalComprehensive` detection from glossarist@0.4.52.
15
+ *
12
16
  * Header badges communicate metadata that the diagram's line styles
13
17
  * also encode — for users who can't perceive the visual difference
14
18
  * (screen readers, low vision, monochrome displays).
@@ -21,6 +25,7 @@ import { useI18n, locale } from '../i18n';
21
25
  import { completenessLabel } from '../utils/partitive-relation-styling';
22
26
  import { presenceLabel, countLabel } from '../utils/partitive-multiplicity';
23
27
  import { resolveDesignation } from '../utils/resolve-designation';
28
+ import { isExternalMember, isExternalComprehensive, formatExternalLabel } from '../utils/external-detection';
24
29
  import PartitiveRelationDiagram, {
25
30
  type PartitiveMemberLabeled,
26
31
  } from './PartitiveRelationDiagram.vue';
@@ -36,8 +41,51 @@ const store = useVocabularyStore();
36
41
  const factory = getFactory();
37
42
  const { t } = useI18n();
38
43
 
39
- function designationFor(uri: string): string {
40
- return resolveDesignation(uri, store, factory, locale.value);
44
+ /** Per-URI cache of external-status lookups, so the rake doesn't re-resolve
45
+ * the same concept on every render pass. */
46
+ const externalCache = new Map<string, boolean>();
47
+
48
+ /**
49
+ * Bridge the concept-browser ref shape to glossarist@0.4.52's ConceptStore
50
+ * interface. The store exposes concepts via `lookup({ source, id })` and
51
+ * returns an object with `.status` + `.related[]` — exactly what the
52
+ * upstream external-detection utilities need.
53
+ */
54
+ const externalStore = {
55
+ lookup(ref: { source?: string | null; id?: string | null }) {
56
+ if (!ref?.source && !ref?.id) return null;
57
+ // Find the matching concept by traversing the loaded datasets.
58
+ for (const adapter of store.datasets.values()) {
59
+ for (const entry of adapter.getConcepts() ?? []) {
60
+ if (entry && entry.id === ref.id) {
61
+ return {
62
+ status: entry.status,
63
+ related: (entry as any).relatedConcepts ?? (entry as any).related ?? [],
64
+ };
65
+ }
66
+ }
67
+ }
68
+ return null;
69
+ },
70
+ };
71
+
72
+ function isExternalUri(uri: string, kind: 'member' | 'comprehensive'): boolean {
73
+ if (externalCache.has(uri)) return externalCache.get(uri)!;
74
+ // Build a member/hyperedge shape the upstream utilities expect.
75
+ const ref = { source: uri, id: uri.split('/').pop() ?? uri };
76
+ let isExt: boolean;
77
+ if (kind === 'comprehensive') {
78
+ isExt = isExternalComprehensive({ comprehensive: ref, members: [] }, externalStore);
79
+ } else {
80
+ isExt = isExternalMember({ ref }, externalStore);
81
+ }
82
+ externalCache.set(uri, isExt);
83
+ return isExt;
84
+ }
85
+
86
+ function designationFor(uri: string, kind: 'member' | 'comprehensive' = 'member'): string {
87
+ const label = resolveDesignation(uri, store, factory, locale.value);
88
+ return formatExternalLabel(label, isExternalUri(uri, kind));
41
89
  }
42
90
 
43
91
  function navigate(uri: string) {
@@ -67,7 +115,7 @@ function criterionText(criterion?: Record<string, string>): string | null {
67
115
  function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
68
116
  return rel.partitives.map(m => ({
69
117
  uri: m.uri,
70
- label: designationFor(m.uri),
118
+ label: designationFor(m.uri, 'member'),
71
119
  presence: m.presence,
72
120
  count: m.count,
73
121
  isDelimiting: m.isDelimiting,
@@ -1,70 +1,97 @@
1
1
  /**
2
2
  * External concept detection — ISO 704:2022 external concepts.
3
3
  *
4
- * An "external concept" is one referenced from this dataset but defined
5
- * elsewhere (status: 'external'). In diagrams, external concepts are
6
- * shown in parentheses with a dashed border per ISO 704:2022 §5.5.4.3.1.
4
+ * The detection logic now lives upstream in glossarist@0.4.52 — this
5
+ * file is a thin re-export so consumers don't have to know which package
6
+ * the functions live in. The signature is also richer upstream: it
7
+ * understands `MemberLike` (with nested `.ref`) and `HyperedgeLike`
8
+ * shapes, matching the actual glossarist-js model.
7
9
  *
8
- * Detection requires concept resolution (looking up the referenced
9
- * concept's status), which is a consumer concern — the model itself
10
- * stays pure. These utility functions take a conceptStore/lookup
11
- * callback provided by the caller.
10
+ * The only thing kept locally is `formatExternalLabel` — it's a pure
11
+ * UI concern (parentheses per ISO 704 §5.5.4.3.1) and doesn't belong
12
+ * in the model library.
13
+ *
14
+ * ISO 704:2022 §5.5.4.3.1: external concepts are shown in parentheses
15
+ * with a dashed border in diagrams.
12
16
  */
13
17
 
14
- export interface ConceptLike {
15
- status?: string | null;
16
- relatedConcepts?: ReadonlyArray<{ type?: string | null }>;
17
- }
18
+ import {
19
+ isExternalConcept,
20
+ isExternalMember as glsIsExternalMember,
21
+ isExternalComprehensive as glsIsExternalComprehensive,
22
+ getExternalMembers as glsGetExternalMembers,
23
+ hasProvidedBy,
24
+ hasDanglingExternal as glsHasDanglingExternal,
25
+ } from 'glossarist';
26
+ import type {
27
+ ExternalConceptLike,
28
+ ConceptStore,
29
+ } from 'glossarist';
18
30
 
19
- export interface ConceptStoreLike {
20
- lookup(ref: { source?: string | null; id?: string | null }): ConceptLike | null;
21
- }
31
+ export type {
32
+ ExternalConceptLike,
33
+ ConceptStore,
34
+ };
22
35
 
23
- export interface RefLike {
24
- source?: string | null;
25
- id?: string | null;
26
- }
36
+ export {
37
+ isExternalConcept,
38
+ hasProvidedBy,
39
+ };
27
40
 
28
41
  /**
29
- * Check if a member's concept resolves to status: external.
42
+ * Defensive wrappers around the upstream hyperedge-aware queries.
43
+ * glossarist@0.4.52 throws on null `store` (it indexes into it
44
+ * unconditionally). UI consumers may have no store available (e.g.,
45
+ * during initial render before the vocabulary store is loaded) —
46
+ * return false / [] instead of propagating the throw.
30
47
  */
31
- export function isExternalConcept(
32
- ref: RefLike | null | undefined,
33
- store: ConceptStoreLike | null | undefined,
48
+ export function isExternalMember(
49
+ member: { ref?: unknown } | null | undefined,
50
+ store: ConceptStore | null | undefined,
34
51
  ): boolean {
35
- if (!ref || !store) return false;
36
- const concept = store.lookup(ref);
37
- return concept?.status === 'external';
52
+ if (!member || !store) return false;
53
+ return glsIsExternalMember(member as Parameters<typeof glsIsExternalMember>[0], store);
38
54
  }
39
55
 
40
- /**
41
- * Check if an external concept has a provided_by edge for resolution.
42
- * Without provided_by, the reference dangles — the decomposition is
43
- * incomplete.
44
- */
45
- export function hasProvidedBy(
46
- ref: RefLike | null | undefined,
47
- store: ConceptStoreLike | null | undefined,
56
+ export function isExternalComprehensive(
57
+ hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
58
+ store: ConceptStore | null | undefined,
48
59
  ): boolean {
49
- if (!ref || !store) return false;
50
- const concept = store.lookup(ref);
51
- if (!concept?.relatedConcepts) return false;
52
- return concept.relatedConcepts.some(rc => rc.type === 'provided_by');
60
+ if (!hyperedge || !store) return false;
61
+ return glsIsExternalComprehensive(
62
+ hyperedge as Parameters<typeof glsIsExternalComprehensive>[0],
63
+ store,
64
+ );
53
65
  }
54
66
 
55
- /**
56
- * Check if an external concept's reference dangles (no provided_by edge).
57
- */
58
- export function isDanglingExternal(
59
- ref: RefLike | null | undefined,
60
- store: ConceptStoreLike | null | undefined,
67
+ export function getExternalMembers(
68
+ hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
69
+ store: ConceptStore | null | undefined,
70
+ ): { ref?: unknown }[] {
71
+ if (!hyperedge || !store) return [];
72
+ return glsGetExternalMembers(
73
+ hyperedge as Parameters<typeof glsGetExternalMembers>[0],
74
+ store,
75
+ );
76
+ }
77
+
78
+ export function hasDanglingExternal(
79
+ hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
80
+ store: ConceptStore | null | undefined,
61
81
  ): boolean {
62
- return isExternalConcept(ref, store) && !hasProvidedBy(ref, store);
82
+ if (!hyperedge || !store) return false;
83
+ return glsHasDanglingExternal(
84
+ hyperedge as Parameters<typeof glsHasDanglingExternal>[0],
85
+ store,
86
+ );
63
87
  }
64
88
 
65
89
  /**
66
90
  * Format a label for an external concept — parenthetical per ISO 704.
67
91
  * Returns `"(label)"` for external concepts, `label` for internal.
92
+ *
93
+ * Kept locally because it's a pure UI concern (rendering decision), not
94
+ * a model-layer classification.
68
95
  */
69
96
  export function formatExternalLabel(
70
97
  label: string,