@rsconcept/domain 2.2.0 → 3.0.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 (55) hide show
  1. package/dist/{diagnostics-Db1F7ruF.d.ts → diagnostics-DsEhmFh0.d.ts} +6 -4
  2. package/dist/{diagnostics-B1OfMra1.js → diagnostics-ndGYbvgh.js} +374 -15
  3. package/dist/diagnostics-ndGYbvgh.js.map +1 -0
  4. package/dist/index-DpiTybDB.d.ts +376 -0
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/library/diagnostics.d.ts +1 -1
  8. package/dist/library/diagnostics.js +1 -1
  9. package/dist/library/index.d.ts +6 -5
  10. package/dist/library/index.js +1 -1
  11. package/dist/library/index.js.map +1 -1
  12. package/dist/library/oss-api.d.ts +1 -1
  13. package/dist/library/oss-api.js +1 -1
  14. package/dist/library/oss-api.js.map +1 -1
  15. package/dist/library/rsengine.d.ts +1 -1
  16. package/dist/library/rsengine.js +2 -2
  17. package/dist/library/rsform-api/index.d.ts +2 -146
  18. package/dist/library/rsform-api/index.js +2 -2
  19. package/dist/library/rsform.d.ts +2 -2
  20. package/dist/library/rsform.js.map +1 -1
  21. package/dist/library/rsmodel-api.d.ts +9 -4
  22. package/dist/library/rsmodel-api.js +7 -3
  23. package/dist/library/rsmodel-api.js.map +1 -1
  24. package/dist/library/rsmodel.d.ts +1 -1
  25. package/dist/library/structure-planner.d.ts +1 -1
  26. package/dist/library/structure-planner.js +2 -2
  27. package/dist/library/structure-planner.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/index.ts +1 -0
  30. package/src/library/diagnostics.ts +20 -9
  31. package/src/library/index.ts +1 -0
  32. package/src/library/oss-api.ts +5 -1
  33. package/src/library/rsform-api/alias.ts +60 -4
  34. package/src/library/rsform-api/attribution.ts +87 -0
  35. package/src/library/rsform-api/cst-type.ts +7 -5
  36. package/src/library/rsform-api/expression.ts +3 -3
  37. package/src/library/rsform-api/formal-graph.test.ts +2 -2
  38. package/src/library/rsform-api/formal-graph.ts +6 -10
  39. package/src/library/rsform-api/index.ts +38 -2
  40. package/src/library/rsform-api/semantic-relations.ts +4 -7
  41. package/src/library/rsform-api/stats.ts +6 -5
  42. package/src/library/rsform-api/structure.ts +12 -6
  43. package/src/library/rsform-api/substitution.ts +82 -0
  44. package/src/library/rsform-api/synthesis.test.ts +157 -0
  45. package/src/library/rsform-api/synthesis.ts +149 -6
  46. package/src/library/rsform-api/text-resolution.test.ts +84 -0
  47. package/src/library/rsform-api/text-resolution.ts +152 -0
  48. package/src/library/rsform-api/transforms.test.ts +161 -0
  49. package/src/library/rsform-api/transforms.ts +42 -0
  50. package/src/library/rsform-api/types.ts +102 -4
  51. package/src/library/rsform.ts +1 -1
  52. package/src/library/rsmodel-api.ts +10 -5
  53. package/src/library/structure-planner.ts +6 -2
  54. package/src/library/template-instantiation.ts +11 -9
  55. package/dist/diagnostics-B1OfMra1.js.map +0 -1
@@ -4,14 +4,19 @@
4
4
 
5
5
  export {
6
6
  addAliasReference,
7
+ allocateImportAliases,
7
8
  applyMappingToConstituents,
8
9
  argumentValuesToMapping,
10
+ buildSequentialAliasMapping,
9
11
  generateAlias,
12
+ type MappableFields,
13
+ maxAliasIndex,
10
14
  removeAliasReference,
11
15
  validateAliasFormat,
12
16
  validateNewAlias
13
17
  } from './alias';
14
18
  export { getAnalysisFor, refineAnalysisForDependencyCycles } from './analysis';
19
+ export { filterAttributions, importAttributions, remapAttributionsUnderSubstitution } from './attribution';
15
20
  export {
16
21
  canHaveManualTypification,
17
22
  canProduceStructure,
@@ -26,7 +31,38 @@ export {
26
31
  typeClassForCstType
27
32
  } from './cst-type';
28
33
  export { applyFilterCategory, inferStatus, inferTemplate, inferTemplatedType, normalizeExpression } from './expression';
29
- export { restoreConstituentOrder } from './formal-graph';
34
+ export { buildFormalDependencyGraph, restoreConstituentOrder } from './formal-graph';
30
35
  export { calculateSchemaStats, isSchemaIssue } from './stats';
31
36
  export { findCstByStructure, getStructureName, inferNewSpawnPosition } from './structure';
32
- export { sortItemsForInlineSynthesis } from './synthesis';
37
+ export { applyConstituentSubstitutions, remapSubstitutionsAfterImport, type SubstitutionResult } from './substitution';
38
+ export {
39
+ inlineSynthesis,
40
+ type InlineSynthesisInput,
41
+ type InlineSynthesisResult,
42
+ sortItemsForInlineSynthesis,
43
+ type SynthesizableFields
44
+ } from './synthesis';
45
+ export {
46
+ buildDefinitionReferenceGraph,
47
+ buildTermReferenceGraph,
48
+ resolveAllConstituentTexts,
49
+ resolveConstituentTextChange,
50
+ type TextChangeOptions
51
+ } from './text-resolution';
52
+ export { insertItemAfter, moveIdsInOrder, reorderItemsByIds } from './transforms';
53
+ export type {
54
+ AliasTypedFields,
55
+ BasicTextCheckFields,
56
+ DescribableFields,
57
+ DiagnosticSourceFields,
58
+ FormalOrderFields,
59
+ ModelEvalFields,
60
+ SchemaIssueFields,
61
+ SearchableFields,
62
+ SemanticRelations,
63
+ SpawnPathFields,
64
+ StructureCapableFields,
65
+ TemplateParamSourceFields,
66
+ TemplateSourceFields,
67
+ TextResolvableFields
68
+ } from './types';
@@ -8,16 +8,13 @@ import { CstType } from '../rsform';
8
8
 
9
9
  import { isBaseSet, isFunctional } from './cst-type';
10
10
  import { inferTemplate } from './expression';
11
- import { type OrderableConstituenta, type SemanticRelations } from './types';
11
+ import { type FormalOrderFields, type SemanticRelations } from './types';
12
12
 
13
13
  /**
14
14
  * Infer semantic parent/children used for clustering during restore order.
15
15
  * Port of backend {@code SemanticInfo}.
16
16
  */
17
- export function computeSemanticRelations(
18
- items: readonly OrderableConstituenta[],
19
- graph: Graph<number>
20
- ): SemanticRelations {
17
+ export function computeSemanticRelations(items: readonly FormalOrderFields[], graph: Graph<number>): SemanticRelations {
21
18
  const byId = new Map(items.map(cst => [cst.id, cst]));
22
19
  const byAlias = new Map(items.map(cst => [cst.alias, cst]));
23
20
  const isTemplate = new Map<number, boolean>();
@@ -41,7 +38,7 @@ export function computeSemanticRelations(
41
38
  return !isFunctional(base.cst_type) || splitTemplateDefinition(base.definition_formal).head !== head;
42
39
  }
43
40
 
44
- function extractSources(target: OrderableConstituenta): Set<number> {
41
+ function extractSources(target: FormalOrderFields): Set<number> {
45
42
  const sources = new Set<number>();
46
43
  if (!isFunctional(target.cst_type)) {
47
44
  for (const parentId of graph.at(target.id)?.inputs ?? []) {
@@ -76,7 +73,7 @@ export function computeSemanticRelations(
76
73
  return sources;
77
74
  }
78
75
 
79
- function inferSimple(target: OrderableConstituenta): boolean {
76
+ function inferSimple(target: FormalOrderFields): boolean {
80
77
  if (target.cst_type === CstType.STRUCTURED || isBaseSet(target.cst_type)) {
81
78
  return false;
82
79
  }
@@ -4,12 +4,13 @@
4
4
 
5
5
  import { TypeID, type Typification, ValueClass } from '../../rslang';
6
6
  import { hasCstDiagnostic, RSDiagnosticCode } from '../diagnostics';
7
- import { type Constituenta, CstStatus, CstType, type RSForm, type RSFormStats } from '../rsform';
7
+ import { CstStatus, CstType, type RSForm, type RSFormStats } from '../rsform';
8
8
 
9
9
  import { isBasicConcept, isLogical } from './cst-type';
10
+ import { type BasicTextCheckFields, type SchemaIssueFields, type StructureCapableFields } from './types';
10
11
 
11
- /** Checks if {@link Constituenta} is a schema issue. */
12
- export function isSchemaIssue(cst: Constituenta): boolean {
12
+ /** Checks if an item is a schema issue. */
13
+ export function isSchemaIssue(cst: SchemaIssueFields): boolean {
13
14
  return (
14
15
  (cst.diagnostics?.length ?? 0) > 0 || cst.status === CstStatus.INCORRECT || cst.status === CstStatus.INCALCULABLE
15
16
  );
@@ -125,7 +126,7 @@ export function calculateSchemaStats(target: RSForm): RSFormStats {
125
126
  return stats;
126
127
  }
127
128
 
128
- function calculateStepComplexity(cst: Constituenta): number {
129
+ function calculateStepComplexity(cst: StructureCapableFields): number {
129
130
  if (cst.cst_type === CstType.AXIOM || cst.cst_type === CstType.NOMINAL || !isBasicConcept(cst.cst_type)) {
130
131
  return 0;
131
132
  }
@@ -162,7 +163,7 @@ function calculateTypificationComplexity(type: Typification): number {
162
163
  }
163
164
 
164
165
  /** Non-logical basic concept missing convention and/or resolved term (trimmed). */
165
- function isMissingBasicText(cst: Constituenta): boolean {
166
+ function isMissingBasicText(cst: BasicTextCheckFields): boolean {
166
167
  if (!isBasicConcept(cst.cst_type) || isLogical(cst.cst_type)) {
167
168
  return false;
168
169
  }
@@ -7,6 +7,8 @@ import { isTypification } from '../../rslang/semantic/typification';
7
7
  import { applyPath } from '../../rslang/semantic/typification-api';
8
8
  import { type Constituenta, type RSForm } from '../rsform';
9
9
 
10
+ import { type SpawnPathFields, type StructureCapableFields } from './types';
11
+
10
12
  /**
11
13
  * Id to pass as `insert_after` when creating a new {@link Constituenta} spawned by `targetId`:
12
14
  * after the last existing child of that spawner, or after the spawner itself if none.
@@ -21,8 +23,8 @@ export function inferNewSpawnPosition(schema: RSForm, targetId: number): number
21
23
  return last ?? targetId;
22
24
  }
23
25
 
24
- /** Finds {@link Constituenta} by structure path. */
25
- export function findCstByStructure(schema: RSForm, target: Constituenta, path: TypePath): Constituenta | null {
26
+ /** Finds schema item by absolute structure path under `targetId`. */
27
+ export function findCstByStructure(schema: RSForm, target: { id: number }, path: TypePath): Constituenta | null {
26
28
  const byId = schema.cstByID ?? new Map(schema.items.map(item => [item.id, item]));
27
29
  for (const cst of schema.items) {
28
30
  if (!cst.spawner_path) {
@@ -36,8 +38,12 @@ export function findCstByStructure(schema: RSForm, target: Constituenta, path: T
36
38
  return null;
37
39
  }
38
40
 
39
- /** Retrieves name for piece of target {@link Constituenta} structure. */
40
- export function getStructureName(schema: RSForm, target: Constituenta, path: TypePath): string {
41
+ /** Retrieves name for piece of target structure. */
42
+ export function getStructureName(
43
+ schema: RSForm,
44
+ target: StructureCapableFields & { id: number },
45
+ path: TypePath
46
+ ): string {
41
47
  const representation = findCstByStructure(schema, target, path);
42
48
  if (representation) {
43
49
  return `${representation.alias}: ${representation.term_resolved}`;
@@ -56,9 +62,9 @@ export function getStructureName(schema: RSForm, target: Constituenta, path: Typ
56
62
  }
57
63
 
58
64
  function resolveAbsoluteSpawnerPath(
59
- cst: Constituenta,
65
+ cst: SpawnPathFields,
60
66
  targetId: number,
61
- byId: Map<number, Constituenta>
67
+ byId: Map<number, SpawnPathFields>
62
68
  ): TypePath | null {
63
69
  if (!cst.spawner_path || cst.spawner == null) {
64
70
  return null;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Constituenta substitution and post-import substitution remapping.
3
+ */
4
+
5
+ import { type AliasMapping } from '../../rslang/api';
6
+ import { type Attribution, type Substitution } from '../rsform';
7
+
8
+ import { applyMappingToConstituents, type MappableFields } from './alias';
9
+ import { remapAttributionsUnderSubstitution } from './attribution';
10
+
11
+ /** Result of applying substitutions to a schema fragment. */
12
+ export interface SubstitutionResult<T> {
13
+ items: T[];
14
+ attributions: Attribution[];
15
+ deletedIds: Set<number>;
16
+ /** Replacement constituent ids (survivors referenced by substitutions). */
17
+ replacementIds: number[];
18
+ }
19
+
20
+ /**
21
+ * Apply constituenta substitutions: remap attributions, drop originals, rewrite aliases in survivors.
22
+ * Does not resolve texts — call {@link resolveAllConstituentTexts} afterwards.
23
+ */
24
+ export function applyConstituentSubstitutions<T extends MappableFields & { id: number; alias: string }>(
25
+ items: readonly T[],
26
+ attributions: readonly Attribution[],
27
+ substitutions: readonly Substitution[]
28
+ ): SubstitutionResult<T> {
29
+ if (substitutions.length === 0) {
30
+ return {
31
+ items: [...items],
32
+ attributions: attributions.map(attr => ({ ...attr })),
33
+ deletedIds: new Set(),
34
+ replacementIds: []
35
+ };
36
+ }
37
+
38
+ const byId = new Map(items.map(cst => [cst.id, cst]));
39
+ const mapping: AliasMapping = {};
40
+ const deletedIds = new Set<number>();
41
+ const origToSub = new Map<number, number>();
42
+ const replacementIds: number[] = [];
43
+
44
+ for (const { original, substitution } of substitutions) {
45
+ const originalCst = byId.get(original);
46
+ const substitutionCst = byId.get(substitution);
47
+ if (!originalCst || !substitutionCst) {
48
+ throw new Error(`applyConstituentSubstitutions: unknown constituenta ${original} -> ${substitution}`);
49
+ }
50
+ mapping[originalCst.alias] = substitutionCst.alias;
51
+ deletedIds.add(original);
52
+ origToSub.set(original, substitution);
53
+ replacementIds.push(substitution);
54
+ }
55
+
56
+ const nextItems = items.filter(cst => !deletedIds.has(cst.id));
57
+ applyMappingToConstituents(nextItems, mapping, false);
58
+
59
+ return {
60
+ items: nextItems,
61
+ attributions: remapAttributionsUnderSubstitution(attributions, origToSub),
62
+ deletedIds,
63
+ replacementIds
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Remap substitution endpoints after source constituents were inserted under new ids.
69
+ * If {@link original} was imported, map it; otherwise map {@link substitution}.
70
+ */
71
+ export function remapSubstitutionsAfterImport(
72
+ substitutions: readonly Substitution[],
73
+ sourceIdSet: ReadonlySet<number>,
74
+ idMap: Readonly<Record<number, number>>
75
+ ): Substitution[] {
76
+ return substitutions.map(sub => {
77
+ if (sourceIdSet.has(sub.original)) {
78
+ return { original: idMap[sub.original], substitution: sub.substitution };
79
+ }
80
+ return { original: sub.original, substitution: idMap[sub.substitution] };
81
+ });
82
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { LibraryItemType } from '../library';
4
+ import { CstType, type RSForm } from '../rsform';
5
+
6
+ import { inlineSynthesis, sortItemsForInlineSynthesis } from './synthesis';
7
+
8
+ function item(partial: {
9
+ id: number;
10
+ alias: string;
11
+ cst_type: CstType;
12
+ definition_formal?: string;
13
+ term_raw?: string;
14
+ definition_raw?: string;
15
+ }) {
16
+ return {
17
+ id: partial.id,
18
+ alias: partial.alias,
19
+ cst_type: partial.cst_type,
20
+ definition_formal: partial.definition_formal ?? '',
21
+ typification_manual: '',
22
+ term_raw: partial.term_raw ?? '',
23
+ term_resolved: partial.term_raw ?? '',
24
+ term_forms: [] as { text: string; tags: string }[],
25
+ definition_raw: partial.definition_raw ?? '',
26
+ definition_resolved: partial.definition_raw ?? ''
27
+ };
28
+ }
29
+
30
+ describe('inlineSynthesis', () => {
31
+ it('keeps source aliases when receiver is empty', () => {
32
+ const result = inlineSynthesis({
33
+ receiverItems: [],
34
+ sourceItems: [item({ id: 10, alias: 'X9', cst_type: CstType.BASE, term_raw: 'person' })],
35
+ nextId: 1
36
+ });
37
+
38
+ expect(result.items).toHaveLength(1);
39
+ expect(result.items[0].alias).toBe('X9');
40
+ expect(result.items[0].id).toBe(1);
41
+ expect(result.idMap).toEqual({ 10: 1 });
42
+ expect(result.nextId).toBe(2);
43
+ expect(result.aliasMapping).toEqual({});
44
+ });
45
+
46
+ it('allocates fresh aliases when receiver is non-empty', () => {
47
+ const result = inlineSynthesis({
48
+ receiverItems: [item({ id: 1, alias: 'X2', cst_type: CstType.BASE })],
49
+ sourceItems: [item({ id: 10, alias: 'X9', cst_type: CstType.BASE, term_raw: 'source' })],
50
+ nextId: 100
51
+ });
52
+
53
+ expect(result.items).toHaveLength(2);
54
+ expect(result.items[1].alias).toBe('X3');
55
+ expect(result.items[1].id).toBe(100);
56
+ expect(result.aliasMapping).toEqual({ X9: 'X3' });
57
+ });
58
+
59
+ it('applies substitutions after import (source original → receiver survivor)', () => {
60
+ const result = inlineSynthesis({
61
+ receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE, term_raw: 'keep' })],
62
+ sourceItems: [
63
+ item({
64
+ id: 20,
65
+ alias: 'X1',
66
+ cst_type: CstType.BASE,
67
+ term_raw: 'drop',
68
+ definition_formal: ''
69
+ })
70
+ ],
71
+ substitutions: [{ original: 20, substitution: 1 }],
72
+ nextId: 50
73
+ });
74
+
75
+ expect(result.items).toHaveLength(1);
76
+ expect(result.items[0].id).toBe(1);
77
+ expect(result.items[0].term_raw).toBe('keep');
78
+ expect(result.deletedIds).toEqual(new Set([50]));
79
+ expect(result.replacementIds).toEqual([1]);
80
+ });
81
+
82
+ it('remaps formal references when importing into non-empty receiver', () => {
83
+ const result = inlineSynthesis({
84
+ receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })],
85
+ sourceItems: [
86
+ item({ id: 10, alias: 'X1', cst_type: CstType.BASE }),
87
+ item({
88
+ id: 11,
89
+ alias: 'S1',
90
+ cst_type: CstType.STRUCTURED,
91
+ definition_formal: 'ℬ(X1)'
92
+ })
93
+ ],
94
+ nextId: 2
95
+ });
96
+
97
+ const structured = result.items.find(cst => cst.cst_type === CstType.STRUCTURED)!;
98
+ expect(structured.alias).toBe('S1');
99
+ expect(structured.definition_formal).toBe('ℬ(X2)');
100
+ });
101
+
102
+ it('imports attributions with id remapping', () => {
103
+ const result = inlineSynthesis({
104
+ receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })],
105
+ receiverAttributions: [],
106
+ sourceItems: [
107
+ item({ id: 10, alias: 'X1', cst_type: CstType.BASE }),
108
+ item({ id: 11, alias: 'S1', cst_type: CstType.STRUCTURED })
109
+ ],
110
+ sourceAttributions: [{ container: 10, attribute: 11 }],
111
+ nextId: 2
112
+ });
113
+
114
+ expect(result.attributions).toEqual([{ container: 2, attribute: 3 }]);
115
+ });
116
+
117
+ it('returns a clone when source is empty', () => {
118
+ const receiver = [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })];
119
+ const result = inlineSynthesis({
120
+ receiverItems: receiver,
121
+ sourceItems: [],
122
+ nextId: 2
123
+ });
124
+ expect(result.items).toEqual(receiver);
125
+ expect(result.items).not.toBe(receiver);
126
+ });
127
+ });
128
+
129
+ describe('sortItemsForInlineSynthesis', () => {
130
+ function libraryItem(partial: { id: number; location: string; owner: number | null; visible: boolean }) {
131
+ return {
132
+ id: partial.id,
133
+ item_type: LibraryItemType.RSFORM,
134
+ alias: `S${partial.id}`,
135
+ title: `Item ${partial.id}`,
136
+ description: '',
137
+ visible: partial.visible,
138
+ read_only: false,
139
+ location: partial.location,
140
+ access_policy: 'public' as const,
141
+ time_create: '',
142
+ time_update: '',
143
+ owner: partial.owner
144
+ };
145
+ }
146
+
147
+ it('orders by location, then owner+visible, then visible, then rest', () => {
148
+ const receiver = { location: '/a', owner: 1 } as RSForm;
149
+ const sameLocation = libraryItem({ id: 1, location: '/a', owner: 99, visible: false });
150
+ const sameOwnerVisible = libraryItem({ id: 2, location: '/b', owner: 1, visible: true });
151
+ const otherVisible = libraryItem({ id: 3, location: '/c', owner: 2, visible: true });
152
+ const hidden = libraryItem({ id: 4, location: '/d', owner: 2, visible: false });
153
+
154
+ const sorted = sortItemsForInlineSynthesis(receiver, [hidden, otherVisible, sameOwnerVisible, sameLocation]);
155
+ expect(sorted.map(item => item.id)).toEqual([1, 2, 3, 4]);
156
+ });
157
+ });
@@ -1,26 +1,169 @@
1
1
  /**
2
- * Inline synthesis helpers for library item selection.
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: «Встраивание схемы»).
3
7
  */
4
8
 
9
+ import { type AliasMapping } from '../../rslang/api';
5
10
  import { type LibraryItem } from '../library';
6
- import { type RSForm } from '../rsform';
11
+ import { type Attribution, type CstType, type RSForm, type Substitution } from '../rsform';
7
12
 
8
- /** Sorts library items relevant for InlineSynthesis with specified {@link RSForm}. */
13
+ import { allocateImportAliases, applyMappingToConstituents, type MappableFields } from './alias';
14
+ import { importAttributions } from './attribution';
15
+ import { applyConstituentSubstitutions, remapSubstitutionsAfterImport } from './substitution';
16
+ import { resolveAllConstituentTexts } from './text-resolution';
17
+ import { type AliasTypedFields, type TextResolvableFields } from './types';
18
+
19
+ /** Field intersection required for embedding / synthesis transforms. */
20
+ export type SynthesizableFields = MappableFields &
21
+ TextResolvableFields &
22
+ AliasTypedFields & {
23
+ id: number;
24
+ alias: string;
25
+ cst_type: CstType;
26
+ };
27
+
28
+ /** Input for {@link inlineSynthesis}. */
29
+ export interface InlineSynthesisInput<T extends SynthesizableFields> {
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 SynthesizableFields> {
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 SynthesizableFields>(
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}. */
9
148
  export function sortItemsForInlineSynthesis(receiver: RSForm, items: readonly LibraryItem[]): LibraryItem[] {
10
149
  const result = items.filter(item => item.location === receiver.location);
150
+ const included = new Set(result);
11
151
  for (const item of items) {
12
- if (item.visible && item.owner === receiver.owner && !result.includes(item)) {
152
+ if (item.visible && item.owner === receiver.owner && !included.has(item)) {
13
153
  result.push(item);
154
+ included.add(item);
14
155
  }
15
156
  }
16
157
  for (const item of items) {
17
- if (item.visible && !result.includes(item)) {
158
+ if (item.visible && !included.has(item)) {
18
159
  result.push(item);
160
+ included.add(item);
19
161
  }
20
162
  }
21
163
  for (const item of items) {
22
- if (!result.includes(item)) {
164
+ if (!included.has(item)) {
23
165
  result.push(item);
166
+ included.add(item);
24
167
  }
25
168
  }
26
169
  return result;
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { resolveConstituentTextChange } from './text-resolution';
4
+ import { type TextResolvableFields } from './types';
5
+
6
+ function row(data: Partial<TextResolvableFields> & Pick<TextResolvableFields, 'alias' | 'id'>): TextResolvableFields {
7
+ return {
8
+ id: data.id,
9
+ alias: data.alias,
10
+ definition_raw: data.definition_raw ?? '',
11
+ definition_resolved: data.definition_resolved ?? '',
12
+ term_raw: data.term_raw ?? data.alias,
13
+ term_resolved: data.term_resolved ?? data.term_raw ?? data.alias,
14
+ term_forms: data.term_forms ?? []
15
+ };
16
+ }
17
+
18
+ describe('resolveConstituentTextChange', () => {
19
+ it('cascades term changes into dependent terms and definitions', () => {
20
+ const items = [
21
+ row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
22
+ row({
23
+ id: 2,
24
+ alias: 'T2',
25
+ term_raw: '@{T1|nomn,sing}',
26
+ term_resolved: 'base concept',
27
+ definition_raw: '@{T1|nomn,sing}',
28
+ definition_resolved: 'base concept',
29
+ term_forms: [{ text: 'stale manual form', tags: 'gent,sing' }]
30
+ }),
31
+ row({ id: 3, alias: 'T3', definition_raw: '@{T2|nomn,sing}', definition_resolved: 'base concept' })
32
+ ];
33
+
34
+ items[0].term_raw = 'updated concept';
35
+ resolveConstituentTextChange(items, 1, {
36
+ termChanged: true,
37
+ termRawChanged: true,
38
+ definitionRawChanged: false,
39
+ clearTargetForms: true
40
+ });
41
+
42
+ expect(items[0].term_resolved).toBe('updated concept');
43
+ expect(items[1].term_resolved).toBe('updated concept');
44
+ expect(items[1].term_forms).toStrictEqual([]);
45
+ expect(items[1].definition_resolved).toBe('updated concept');
46
+ expect(items[2].definition_resolved).toBe('updated concept');
47
+ });
48
+
49
+ it('uses updated manual forms as a term change trigger', () => {
50
+ const items = [
51
+ row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
52
+ row({ id: 2, alias: 'T2', term_raw: '@{T1|gent,sing}', term_resolved: 'base concept' })
53
+ ];
54
+
55
+ items[0].term_forms = [{ text: 'manual genitive singular', tags: 'gent,sing' }];
56
+ resolveConstituentTextChange(items, 1, {
57
+ termChanged: true,
58
+ termRawChanged: false,
59
+ definitionRawChanged: false,
60
+ clearTargetForms: false
61
+ });
62
+
63
+ expect(items[1].term_resolved).toBe('manual genitive singular');
64
+ });
65
+
66
+ it('does not cascade definition-only edits', () => {
67
+ const items = [
68
+ row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
69
+ row({ id: 2, alias: 'T2', definition_raw: '@{T1|nomn,sing}', definition_resolved: 'base concept' }),
70
+ row({ id: 3, alias: 'T3', definition_raw: '@{T2|nomn,sing}', definition_resolved: 'old T2' })
71
+ ];
72
+
73
+ items[1].definition_raw = 'Only @{T1|nomn,sing}';
74
+ resolveConstituentTextChange(items, 2, {
75
+ termChanged: false,
76
+ termRawChanged: false,
77
+ definitionRawChanged: true,
78
+ clearTargetForms: false
79
+ });
80
+
81
+ expect(items[1].definition_resolved).toBe('Only base concept');
82
+ expect(items[2].definition_resolved).toBe('old T2');
83
+ });
84
+ });