@rsconcept/domain 2.1.2 → 2.3.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 (62) hide show
  1. package/dist/api-Bf0f_uiI.d.ts +57 -0
  2. package/dist/diagnostics-CljEzVBV.js +1245 -0
  3. package/dist/diagnostics-CljEzVBV.js.map +1 -0
  4. package/dist/{diagnostics-EaIt3zIw.d.ts → diagnostics-Dac5sJhO.d.ts} +4 -4
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +3 -3
  7. package/dist/library/diagnostics.d.ts +1 -1
  8. package/dist/library/diagnostics.js +1 -1
  9. package/dist/library/index.d.ts +2 -2
  10. package/dist/library/index.js +1 -1
  11. package/dist/library/oss-api.d.ts +1 -1
  12. package/dist/library/oss-api.js +4 -4
  13. package/dist/library/oss-api.js.map +1 -1
  14. package/dist/library/rsengine.d.ts +1 -1
  15. package/dist/library/rsengine.js +2 -2
  16. package/dist/library/rsform-api/index.d.ts +299 -0
  17. package/dist/library/rsform-api/index.js +2 -0
  18. package/dist/library/rsform.d.ts +2 -2
  19. package/dist/library/rsform.js.map +1 -1
  20. package/dist/library/rsmodel-api.d.ts +1 -1
  21. package/dist/library/rsmodel-api.js +4 -2
  22. package/dist/library/rsmodel-api.js.map +1 -1
  23. package/dist/library/rsmodel.d.ts +1 -1
  24. package/dist/library/structure-planner.d.ts +1 -1
  25. package/dist/library/structure-planner.js +2 -2
  26. package/dist/rslang/api.d.ts +2 -58
  27. package/dist/rslang/api.js +1 -1
  28. package/dist/rslang/index.js +1 -1
  29. package/dist/rslang/parser/syntax-errors/index.js +1 -1
  30. package/dist/rslang/semantic/analyzer.js +2 -2
  31. package/dist/{syntax-errors-DwPXhpVe.js → syntax-errors-BN_bqTaX.js} +2 -2
  32. package/dist/{syntax-errors-DwPXhpVe.js.map → syntax-errors-BN_bqTaX.js.map} +1 -1
  33. package/package.json +3 -3
  34. package/src/index.ts +1 -0
  35. package/src/library/index.ts +1 -0
  36. package/src/library/oss-api.ts +5 -2
  37. package/src/library/rsform-api/alias.ts +160 -0
  38. package/src/library/rsform-api/analysis.ts +157 -0
  39. package/src/library/rsform-api/attribution.ts +87 -0
  40. package/src/library/rsform-api/cst-type.ts +216 -0
  41. package/src/library/rsform-api/expression.ts +73 -0
  42. package/src/library/rsform-api/formal-graph.test.ts +72 -0
  43. package/src/library/rsform-api/formal-graph.ts +195 -0
  44. package/src/library/rsform-api/index.ts +53 -0
  45. package/src/library/rsform-api/semantic-relations.ts +117 -0
  46. package/src/library/rsform-api/stats.ts +170 -0
  47. package/src/library/rsform-api/structure.ts +93 -0
  48. package/src/library/rsform-api/substitution.ts +82 -0
  49. package/src/library/rsform-api/synthesis.test.ts +126 -0
  50. package/src/library/rsform-api/synthesis.ts +166 -0
  51. package/src/library/rsform-api/text-resolution.test.ts +86 -0
  52. package/src/library/rsform-api/text-resolution.ts +152 -0
  53. package/src/library/rsform-api/transforms.test.ts +161 -0
  54. package/src/library/rsform-api/transforms.ts +42 -0
  55. package/src/library/rsform-api/types.ts +38 -0
  56. package/src/library/rsform.ts +3 -3
  57. package/src/library/rsmodel-api.ts +7 -1
  58. package/dist/diagnostics-CY9sKggB.js +0 -658
  59. package/dist/diagnostics-CY9sKggB.js.map +0 -1
  60. package/dist/library/rsform-api.d.ts +0 -103
  61. package/dist/library/rsform-api.js +0 -2
  62. package/src/library/rsform-api.ts +0 -755
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { CstType } from '../rsform';
4
+
5
+ import { inlineSynthesis } from './synthesis';
6
+
7
+ function item(partial: {
8
+ id: number;
9
+ alias: string;
10
+ cst_type: CstType;
11
+ definition_formal?: string;
12
+ term_raw?: string;
13
+ definition_raw?: string;
14
+ }) {
15
+ return {
16
+ id: partial.id,
17
+ alias: partial.alias,
18
+ cst_type: partial.cst_type,
19
+ definition_formal: partial.definition_formal ?? '',
20
+ typification_manual: '',
21
+ term_raw: partial.term_raw ?? '',
22
+ term_resolved: partial.term_raw ?? '',
23
+ term_forms: [] as { text: string; tags: string }[],
24
+ definition_raw: partial.definition_raw ?? '',
25
+ definition_resolved: partial.definition_raw ?? ''
26
+ };
27
+ }
28
+
29
+ describe('inlineSynthesis', () => {
30
+ it('keeps source aliases when receiver is empty', () => {
31
+ const result = inlineSynthesis({
32
+ receiverItems: [],
33
+ sourceItems: [item({ id: 10, alias: 'X9', cst_type: CstType.BASE, term_raw: 'person' })],
34
+ nextId: 1
35
+ });
36
+
37
+ expect(result.items).toHaveLength(1);
38
+ expect(result.items[0].alias).toBe('X9');
39
+ expect(result.items[0].id).toBe(1);
40
+ expect(result.idMap).toEqual({ 10: 1 });
41
+ expect(result.nextId).toBe(2);
42
+ expect(result.aliasMapping).toEqual({});
43
+ });
44
+
45
+ it('allocates fresh aliases when receiver is non-empty', () => {
46
+ const result = inlineSynthesis({
47
+ receiverItems: [item({ id: 1, alias: 'X2', cst_type: CstType.BASE })],
48
+ sourceItems: [item({ id: 10, alias: 'X9', cst_type: CstType.BASE, term_raw: 'source' })],
49
+ nextId: 100
50
+ });
51
+
52
+ expect(result.items).toHaveLength(2);
53
+ expect(result.items[1].alias).toBe('X3');
54
+ expect(result.items[1].id).toBe(100);
55
+ expect(result.aliasMapping).toEqual({ X9: 'X3' });
56
+ });
57
+
58
+ it('applies substitutions after import (source original → receiver survivor)', () => {
59
+ const result = inlineSynthesis({
60
+ receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE, term_raw: 'keep' })],
61
+ sourceItems: [
62
+ item({
63
+ id: 20,
64
+ alias: 'X1',
65
+ cst_type: CstType.BASE,
66
+ term_raw: 'drop',
67
+ definition_formal: ''
68
+ })
69
+ ],
70
+ substitutions: [{ original: 20, substitution: 1 }],
71
+ nextId: 50
72
+ });
73
+
74
+ expect(result.items).toHaveLength(1);
75
+ expect(result.items[0].id).toBe(1);
76
+ expect(result.items[0].term_raw).toBe('keep');
77
+ expect(result.deletedIds).toEqual(new Set([50]));
78
+ expect(result.replacementIds).toEqual([1]);
79
+ });
80
+
81
+ it('remaps formal references when importing into non-empty receiver', () => {
82
+ const result = inlineSynthesis({
83
+ receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })],
84
+ sourceItems: [
85
+ item({ id: 10, alias: 'X1', cst_type: CstType.BASE }),
86
+ item({
87
+ id: 11,
88
+ alias: 'S1',
89
+ cst_type: CstType.STRUCTURED,
90
+ definition_formal: 'ℬ(X1)'
91
+ })
92
+ ],
93
+ nextId: 2
94
+ });
95
+
96
+ const structured = result.items.find(cst => cst.cst_type === CstType.STRUCTURED)!;
97
+ expect(structured.alias).toBe('S1');
98
+ expect(structured.definition_formal).toBe('ℬ(X2)');
99
+ });
100
+
101
+ it('imports attributions with id remapping', () => {
102
+ const result = inlineSynthesis({
103
+ receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })],
104
+ receiverAttributions: [],
105
+ sourceItems: [
106
+ item({ id: 10, alias: 'X1', cst_type: CstType.BASE }),
107
+ item({ id: 11, alias: 'S1', cst_type: CstType.STRUCTURED })
108
+ ],
109
+ sourceAttributions: [{ container: 10, attribute: 11 }],
110
+ nextId: 2
111
+ });
112
+
113
+ expect(result.attributions).toEqual([{ container: 2, attribute: 3 }]);
114
+ });
115
+
116
+ it('returns a clone when source is empty', () => {
117
+ const receiver = [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })];
118
+ const result = inlineSynthesis({
119
+ receiverItems: receiver,
120
+ sourceItems: [],
121
+ nextId: 2
122
+ });
123
+ expect(result.items).toEqual(receiver);
124
+ expect(result.items).not.toBe(receiver);
125
+ });
126
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Schema embedding (synthesis): insert constituents from a source schema into a receiver,
3
+ * then apply an identification (substitution) table.
4
+ *
5
+ * Mirrors backend `inline_synthesis` / `insert_from` + `substitute`
6
+ * (Portal UI: «Встраивание схемы»).
7
+ */
8
+
9
+ import { type AliasMapping } from '../../rslang/api';
10
+ import { type LibraryItem } from '../library';
11
+ import { type Attribution, type CstType, type RSForm, type Substitution } from '../rsform';
12
+
13
+ import { allocateImportAliases, applyMappingToConstituents, type ConstituentaMappableFields } from './alias';
14
+ import { importAttributions } from './attribution';
15
+ import { applyConstituentSubstitutions, remapSubstitutionsAfterImport } from './substitution';
16
+ import { resolveAllConstituentTexts } from './text-resolution';
17
+ import { type AliasTypedConstituenta, type ResolvableConstituenta } from './types';
18
+
19
+ /** Constituent shape required for embedding / synthesis transforms. */
20
+ export type SynthesizableConstituenta = ConstituentaMappableFields &
21
+ ResolvableConstituenta &
22
+ AliasTypedConstituenta & {
23
+ id: number;
24
+ alias: string;
25
+ cst_type: CstType;
26
+ };
27
+
28
+ /** Input for {@link inlineSynthesis}. */
29
+ export interface InlineSynthesisInput<T extends SynthesizableConstituenta> {
30
+ /** Receiver schema constituents (order preserved; imports append). */
31
+ receiverItems: readonly T[];
32
+ /** Receiver attributions (default empty). */
33
+ receiverAttributions?: readonly Attribution[];
34
+ /**
35
+ * Source constituents to insert (already filtered to the desired subset).
36
+ * Cloned; originals are not mutated.
37
+ */
38
+ sourceItems: readonly T[];
39
+ /** Source attributions whose both endpoints are among {@link sourceItems}. */
40
+ sourceAttributions?: readonly Attribution[];
41
+ /**
42
+ * Identification table using pre-import ids:
43
+ * one endpoint is a source id, the other a receiver id
44
+ * (same rules as backend embedding / InlineSynthesis).
45
+ */
46
+ substitutions?: readonly Substitution[];
47
+ /** Next free numeric id for inserted constituents. */
48
+ nextId: number;
49
+ }
50
+
51
+ /** Result of {@link inlineSynthesis}. */
52
+ export interface InlineSynthesisResult<T extends SynthesizableConstituenta> {
53
+ items: T[];
54
+ attributions: Attribution[];
55
+ /** Updated next-id after inserts. */
56
+ nextId: number;
57
+ /** Source id → inserted id. */
58
+ idMap: Record<number, number>;
59
+ /** Alias remapping applied to imported constituents (empty when receiver was empty). */
60
+ aliasMapping: AliasMapping;
61
+ /** Ids removed by substitutions. */
62
+ deletedIds: Set<number>;
63
+ /** Ids of newly inserted constituents (before any were deleted by substitutions). */
64
+ insertedIds: number[];
65
+ /** Survivor ids referenced by applied substitutions. */
66
+ replacementIds: number[];
67
+ }
68
+
69
+ /**
70
+ * Embed {@link InlineSynthesisInput.sourceItems} into the receiver and apply substitutions.
71
+ * Resolves term/definition entity references on the result.
72
+ * Does not mutate inputs.
73
+ */
74
+ export function inlineSynthesis<T extends SynthesizableConstituenta>(
75
+ input: InlineSynthesisInput<T>
76
+ ): InlineSynthesisResult<T> {
77
+ const sourceItems = input.sourceItems;
78
+ if (sourceItems.length === 0) {
79
+ return {
80
+ items: input.receiverItems.map(item => structuredClone(item)),
81
+ attributions: (input.receiverAttributions ?? []).map(attr => ({ ...attr })),
82
+ nextId: input.nextId,
83
+ idMap: {},
84
+ aliasMapping: {},
85
+ deletedIds: new Set(),
86
+ insertedIds: [],
87
+ replacementIds: []
88
+ };
89
+ }
90
+
91
+ const receiverItems = input.receiverItems.map(item => structuredClone(item));
92
+ let attributions = (input.receiverAttributions ?? []).map(attr => ({ ...attr }));
93
+ const aliasMapping = allocateImportAliases(receiverItems, sourceItems);
94
+ const idMap: Record<number, number> = {};
95
+ const inserted: T[] = [];
96
+ const sourceIdSet = new Set(sourceItems.map(cst => cst.id));
97
+ let nextId = input.nextId;
98
+
99
+ for (const cst of sourceItems) {
100
+ const newId = nextId;
101
+ nextId += 1;
102
+ idMap[cst.id] = newId;
103
+
104
+ const cloned = structuredClone(cst);
105
+ cloned.id = newId;
106
+ if (Object.keys(aliasMapping).length > 0) {
107
+ cloned.alias = aliasMapping[cst.alias];
108
+ applyMappingToConstituents([cloned], aliasMapping, false);
109
+ }
110
+ inserted.push(cloned);
111
+ }
112
+
113
+ attributions = importAttributions(input.sourceAttributions ?? [], sourceIdSet, idMap, attributions);
114
+ const items = receiverItems.concat(inserted);
115
+ resolveAllConstituentTexts(items);
116
+
117
+ const substitutions = input.substitutions ?? [];
118
+ if (substitutions.length === 0) {
119
+ return {
120
+ items,
121
+ attributions,
122
+ nextId,
123
+ idMap,
124
+ aliasMapping,
125
+ deletedIds: new Set(),
126
+ insertedIds: inserted.map(cst => cst.id),
127
+ replacementIds: []
128
+ };
129
+ }
130
+
131
+ const remapped = remapSubstitutionsAfterImport(substitutions, sourceIdSet, idMap);
132
+ const substituted = applyConstituentSubstitutions(items, attributions, remapped);
133
+ resolveAllConstituentTexts(substituted.items);
134
+
135
+ return {
136
+ items: substituted.items,
137
+ attributions: substituted.attributions,
138
+ nextId,
139
+ idMap,
140
+ aliasMapping,
141
+ deletedIds: substituted.deletedIds,
142
+ insertedIds: inserted.map(cst => cst.id),
143
+ replacementIds: substituted.replacementIds
144
+ };
145
+ }
146
+
147
+ /** Sorts library items relevant for schema embedding with specified {@link RSForm}. */
148
+ export function sortItemsForInlineSynthesis(receiver: RSForm, items: readonly LibraryItem[]): LibraryItem[] {
149
+ const result = items.filter(item => item.location === receiver.location);
150
+ for (const item of items) {
151
+ if (item.visible && item.owner === receiver.owner && !result.includes(item)) {
152
+ result.push(item);
153
+ }
154
+ }
155
+ for (const item of items) {
156
+ if (item.visible && !result.includes(item)) {
157
+ result.push(item);
158
+ }
159
+ }
160
+ for (const item of items) {
161
+ if (!result.includes(item)) {
162
+ result.push(item);
163
+ }
164
+ }
165
+ return result;
166
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { resolveConstituentTextChange } from './text-resolution';
4
+ import { type ResolvableConstituenta } from './types';
5
+
6
+ function row(
7
+ data: Partial<ResolvableConstituenta> & Pick<ResolvableConstituenta, 'alias' | 'id'>
8
+ ): ResolvableConstituenta {
9
+ return {
10
+ id: data.id,
11
+ alias: data.alias,
12
+ definition_raw: data.definition_raw ?? '',
13
+ definition_resolved: data.definition_resolved ?? '',
14
+ term_raw: data.term_raw ?? data.alias,
15
+ term_resolved: data.term_resolved ?? data.term_raw ?? data.alias,
16
+ term_forms: data.term_forms ?? []
17
+ };
18
+ }
19
+
20
+ describe('resolveConstituentTextChange', () => {
21
+ it('cascades term changes into dependent terms and definitions', () => {
22
+ const items = [
23
+ row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
24
+ row({
25
+ id: 2,
26
+ alias: 'T2',
27
+ term_raw: '@{T1|nomn,sing}',
28
+ term_resolved: 'base concept',
29
+ definition_raw: '@{T1|nomn,sing}',
30
+ definition_resolved: 'base concept',
31
+ term_forms: [{ text: 'stale manual form', tags: 'gent,sing' }]
32
+ }),
33
+ row({ id: 3, alias: 'T3', definition_raw: '@{T2|nomn,sing}', definition_resolved: 'base concept' })
34
+ ];
35
+
36
+ items[0].term_raw = 'updated concept';
37
+ resolveConstituentTextChange(items, 1, {
38
+ termChanged: true,
39
+ termRawChanged: true,
40
+ definitionRawChanged: false,
41
+ clearTargetForms: true
42
+ });
43
+
44
+ expect(items[0].term_resolved).toBe('updated concept');
45
+ expect(items[1].term_resolved).toBe('updated concept');
46
+ expect(items[1].term_forms).toStrictEqual([]);
47
+ expect(items[1].definition_resolved).toBe('updated concept');
48
+ expect(items[2].definition_resolved).toBe('updated concept');
49
+ });
50
+
51
+ it('uses updated manual forms as a term change trigger', () => {
52
+ const items = [
53
+ row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
54
+ row({ id: 2, alias: 'T2', term_raw: '@{T1|gent,sing}', term_resolved: 'base concept' })
55
+ ];
56
+
57
+ items[0].term_forms = [{ text: 'manual genitive singular', tags: 'gent,sing' }];
58
+ resolveConstituentTextChange(items, 1, {
59
+ termChanged: true,
60
+ termRawChanged: false,
61
+ definitionRawChanged: false,
62
+ clearTargetForms: false
63
+ });
64
+
65
+ expect(items[1].term_resolved).toBe('manual genitive singular');
66
+ });
67
+
68
+ it('does not cascade definition-only edits', () => {
69
+ const items = [
70
+ row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
71
+ row({ id: 2, alias: 'T2', definition_raw: '@{T1|nomn,sing}', definition_resolved: 'base concept' }),
72
+ row({ id: 3, alias: 'T3', definition_raw: '@{T2|nomn,sing}', definition_resolved: 'old T2' })
73
+ ];
74
+
75
+ items[1].definition_raw = 'Only @{T1|nomn,sing}';
76
+ resolveConstituentTextChange(items, 2, {
77
+ termChanged: false,
78
+ termRawChanged: false,
79
+ definitionRawChanged: true,
80
+ clearTargetForms: false
81
+ });
82
+
83
+ expect(items[1].definition_resolved).toBe('Only base concept');
84
+ expect(items[2].definition_resolved).toBe('old T2');
85
+ });
86
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Cascade resolution of constituenta term/definition entity references.
3
+ * Matches backend {@code RSForm.resolve_all_text} / {@code resolve_term_change}.
4
+ */
5
+
6
+ import { type TermContext } from '../../cctext/language';
7
+ import { extractEntities, parseGrammemes, resolveTextReferences } from '../../cctext/language-api';
8
+ import { Graph } from '../../graph';
9
+
10
+ import { type ResolvableConstituenta } from './types';
11
+
12
+ /** Options controlling which texts to re-resolve after an edit. */
13
+ export interface TextChangeOptions {
14
+ termChanged: boolean;
15
+ termRawChanged: boolean;
16
+ definitionRawChanged: boolean;
17
+ clearTargetForms: boolean;
18
+ }
19
+
20
+ /** Build term-reference graph: referenced constituent → dependent (backend {@code graph_term}). */
21
+ export function buildTermReferenceGraph(items: readonly ResolvableConstituenta[]): Graph<number> {
22
+ return buildReferenceGraph(items, 'term_raw');
23
+ }
24
+
25
+ /** Build definition-reference graph: referenced constituent → dependent (backend {@code graph_text}). */
26
+ export function buildDefinitionReferenceGraph(items: readonly ResolvableConstituenta[]): Graph<number> {
27
+ return buildReferenceGraph(items, 'definition_raw');
28
+ }
29
+
30
+ /** Resolve all term and definition texts in topological order. Mutates items in place. */
31
+ export function resolveAllConstituentTexts(items: ResolvableConstituenta[]): void {
32
+ const graphTerm = buildTermReferenceGraph(items);
33
+ const idToItem = new Map(items.map(item => [item.id, item]));
34
+ const context = buildTermContext(items);
35
+
36
+ for (const id of graphTerm.topologicalOrder()) {
37
+ const row = idToItem.get(id);
38
+ if (!row) {
39
+ continue;
40
+ }
41
+ const resolved = resolveTextReferences(row.term_raw, context);
42
+ if (resolved !== row.term_resolved) {
43
+ row.term_resolved = resolved;
44
+ row.term_forms = [];
45
+ context[row.alias] = { nominal: row.term_resolved, forms: [] };
46
+ }
47
+ }
48
+
49
+ for (const row of items) {
50
+ row.definition_resolved = resolveTextReferences(row.definition_raw, context);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve texts after a single constituent change, cascading dependents when the term changed.
56
+ * Mutates items in place.
57
+ */
58
+ export function resolveConstituentTextChange(
59
+ items: ResolvableConstituenta[],
60
+ targetID: number,
61
+ options: TextChangeOptions
62
+ ): void {
63
+ const idToItem = new Map(items.map(item => [item.id, item]));
64
+ const target = idToItem.get(targetID);
65
+ if (!target) {
66
+ throw new Error(`resolveConstituentTextChange: unknown target ${targetID}`);
67
+ }
68
+
69
+ const context = buildTermContext(items);
70
+ if (options.termRawChanged) {
71
+ target.term_resolved = resolveTextReferences(target.term_raw, context);
72
+ if (options.clearTargetForms) {
73
+ target.term_forms = [];
74
+ }
75
+ context[target.alias] = toContextItem(target);
76
+ } else if (options.termChanged) {
77
+ context[target.alias] = toContextItem(target);
78
+ }
79
+
80
+ if (options.definitionRawChanged && !options.termChanged) {
81
+ target.definition_resolved = resolveTextReferences(target.definition_raw, context);
82
+ }
83
+
84
+ if (!options.termChanged) {
85
+ return;
86
+ }
87
+
88
+ const graphTerm = buildTermReferenceGraph(items);
89
+ const graphText = buildDefinitionReferenceGraph(items);
90
+ const expansion = graphTerm.expandAllOutputs([targetID]);
91
+ const expansionSet = new Set(expansion);
92
+
93
+ for (const id of graphTerm.topologicalOrder()) {
94
+ if (!expansionSet.has(id)) {
95
+ continue;
96
+ }
97
+ const expanded = idToItem.get(id);
98
+ if (!expanded) {
99
+ continue;
100
+ }
101
+ const resolved = resolveTextReferences(expanded.term_raw, context);
102
+ if (resolved !== expanded.term_resolved) {
103
+ expanded.term_resolved = resolved;
104
+ expanded.term_forms = [];
105
+ context[expanded.alias] = { nominal: expanded.term_resolved, forms: [] };
106
+ }
107
+ }
108
+
109
+ const updateDefs = new Set([targetID, ...expansion, ...graphText.expandAllOutputs([targetID, ...expansion])]);
110
+ for (const item of items) {
111
+ if (updateDefs.has(item.id)) {
112
+ item.definition_resolved = resolveTextReferences(item.definition_raw, context);
113
+ }
114
+ }
115
+ }
116
+
117
+ // ====== Internals =======
118
+
119
+ function buildReferenceGraph(
120
+ items: readonly ResolvableConstituenta[],
121
+ field: 'definition_raw' | 'term_raw'
122
+ ): Graph<number> {
123
+ const graph = new Graph<number>();
124
+ const idByAlias = new Map(items.map(row => [row.alias, row.id]));
125
+ for (const row of items) {
126
+ graph.addNode(row.id);
127
+ }
128
+ for (const row of items) {
129
+ for (const entity of extractEntities(row[field])) {
130
+ const source = idByAlias.get(entity);
131
+ if (source !== undefined) {
132
+ graph.addEdge(source, row.id);
133
+ }
134
+ }
135
+ }
136
+ return graph;
137
+ }
138
+
139
+ function buildTermContext(items: readonly ResolvableConstituenta[]): TermContext {
140
+ const context: TermContext = {};
141
+ for (const row of items) {
142
+ context[row.alias] = toContextItem(row);
143
+ }
144
+ return context;
145
+ }
146
+
147
+ function toContextItem(row: ResolvableConstituenta): TermContext[string] {
148
+ return {
149
+ nominal: row.term_resolved,
150
+ forms: row.term_forms.map(form => ({ text: form.text, grams: parseGrammemes(form.tags) }))
151
+ };
152
+ }
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { CstType } from '../rsform';
4
+
5
+ import {
6
+ allocateImportAliases,
7
+ applyConstituentSubstitutions,
8
+ buildSequentialAliasMapping,
9
+ filterAttributions,
10
+ importAttributions,
11
+ insertItemAfter,
12
+ maxAliasIndex,
13
+ moveIdsInOrder,
14
+ remapAttributionsUnderSubstitution,
15
+ remapSubstitutionsAfterImport,
16
+ reorderItemsByIds
17
+ } from './index';
18
+
19
+ describe('maxAliasIndex / sequential and import aliases', () => {
20
+ it('finds max numeric suffix per type', () => {
21
+ expect(
22
+ maxAliasIndex(
23
+ [
24
+ { alias: 'X1', cst_type: CstType.BASE },
25
+ { alias: 'X4', cst_type: CstType.BASE }
26
+ ],
27
+ CstType.BASE
28
+ )
29
+ ).toBe(4);
30
+ expect(maxAliasIndex([{ alias: 'D2', cst_type: CstType.TERM }], CstType.BASE)).toBe(0);
31
+ });
32
+
33
+ it('builds sequential alias mapping in list order', () => {
34
+ const mapping = buildSequentialAliasMapping([
35
+ { alias: 'X9', cst_type: CstType.BASE },
36
+ { alias: 'X1', cst_type: CstType.BASE },
37
+ { alias: 'D5', cst_type: CstType.TERM }
38
+ ]);
39
+ expect(mapping).toEqual({ X9: 'X1', X1: 'X2', D5: 'D1' });
40
+ });
41
+
42
+ it('allocates import aliases only when receiver is non-empty', () => {
43
+ expect(allocateImportAliases([], [{ alias: 'X9', cst_type: CstType.BASE }])).toEqual({});
44
+ expect(
45
+ allocateImportAliases([{ alias: 'X2', cst_type: CstType.BASE }], [{ alias: 'X9', cst_type: CstType.BASE }])
46
+ ).toEqual({ X9: 'X3' });
47
+ });
48
+ });
49
+
50
+ describe('attribution remapping', () => {
51
+ it('remaps attributions under substitution and drops self-loops', () => {
52
+ const remapped = remapAttributionsUnderSubstitution(
53
+ [
54
+ { container: 1, attribute: 10 },
55
+ { container: 2, attribute: 20 },
56
+ { container: 1, attribute: 2 }
57
+ ],
58
+ new Map([
59
+ [1, 2],
60
+ [10, 20]
61
+ ])
62
+ );
63
+ expect(remapped).toEqual([{ container: 2, attribute: 20 }]);
64
+ });
65
+
66
+ it('imports attributions with id map and dedupe against existing', () => {
67
+ const imported = importAttributions(
68
+ [
69
+ { container: 10, attribute: 11 },
70
+ { container: 10, attribute: 99 }
71
+ ],
72
+ new Set([10, 11]),
73
+ { 10: 100, 11: 101 },
74
+ [{ container: 100, attribute: 101 }]
75
+ );
76
+ expect(imported).toEqual([{ container: 100, attribute: 101 }]);
77
+ });
78
+
79
+ it('filters attributions by deleted ids', () => {
80
+ expect(
81
+ filterAttributions(
82
+ [
83
+ { container: 1, attribute: 2 },
84
+ { container: 3, attribute: 4 }
85
+ ],
86
+ new Set([1])
87
+ )
88
+ ).toEqual([{ container: 3, attribute: 4 }]);
89
+ });
90
+ });
91
+
92
+ describe('transforms', () => {
93
+ it('moves ids within order', () => {
94
+ expect(moveIdsInOrder([1, 2, 3, 4], [2, 3], 0)).toEqual([2, 3, 1, 4]);
95
+ expect(moveIdsInOrder([1, 2, 3], [3], 99)).toEqual([1, 2, 3]);
96
+ });
97
+
98
+ it('reorders items by ids', () => {
99
+ const items = [
100
+ { id: 1, alias: 'a' },
101
+ { id: 2, alias: 'b' }
102
+ ];
103
+ expect(reorderItemsByIds(items, [2, 1]).map(i => i.alias)).toEqual(['b', 'a']);
104
+ });
105
+
106
+ it('inserts after id or appends', () => {
107
+ const items = [{ id: 1 }, { id: 2 }];
108
+ insertItemAfter(items, { id: 3 }, 1);
109
+ expect(items.map(i => i.id)).toEqual([1, 3, 2]);
110
+ insertItemAfter(items, { id: 4 }, null);
111
+ expect(items.map(i => i.id)).toEqual([1, 3, 2, 4]);
112
+ });
113
+ });
114
+
115
+ describe('substitution', () => {
116
+ it('applies alias mapping and drops originals', () => {
117
+ const items = [
118
+ {
119
+ id: 1,
120
+ alias: 'X1',
121
+ definition_formal: 'X1',
122
+ typification_manual: '',
123
+ term_raw: 'a',
124
+ definition_raw: '@{X1|nomn,sing}'
125
+ },
126
+ {
127
+ id: 2,
128
+ alias: 'X2',
129
+ definition_formal: 'X2',
130
+ typification_manual: '',
131
+ term_raw: 'b',
132
+ definition_raw: ''
133
+ }
134
+ ];
135
+ const result = applyConstituentSubstitutions(
136
+ items,
137
+ [{ container: 1, attribute: 2 }],
138
+ [{ original: 1, substitution: 2 }]
139
+ );
140
+ expect(result.deletedIds).toEqual(new Set([1]));
141
+ expect(result.items).toHaveLength(1);
142
+ expect(result.items[0].definition_raw).toBe('');
143
+ expect(result.attributions).toEqual([]);
144
+ });
145
+
146
+ it('remaps substitutions after import', () => {
147
+ expect(
148
+ remapSubstitutionsAfterImport(
149
+ [
150
+ { original: 10, substitution: 1 },
151
+ { original: 1, substitution: 10 }
152
+ ],
153
+ new Set([10]),
154
+ { 10: 100 }
155
+ )
156
+ ).toEqual([
157
+ { original: 100, substitution: 1 },
158
+ { original: 1, substitution: 100 }
159
+ ]);
160
+ });
161
+ });