Initial SFERA platform baseline

This commit is contained in:
2026-05-16 19:03:49 +03:00
commit 3b845c8fce
282 changed files with 55045 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# sfera-collaboration
In-memory collaboration primitives for SFERA.
Provides:
- users, workspaces, projects, and tasks;
- task-linked change sessions with explicit finish time;
- project/target scoped comments;
- activity feed events;
- project/target scoped ownership assignments.
+10
View File
@@ -0,0 +1,10 @@
[project]
name = "sfera-collaboration"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
]
[tool.uv]
package = true
@@ -0,0 +1,196 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
class TaskStatus(str, Enum):
OPEN = "OPEN"
IN_PROGRESS = "IN_PROGRESS"
DONE = "DONE"
CANCELED = "CANCELED"
class User(BaseModel):
user_id: str
display_name: str
email: str | None = None
class Workspace(BaseModel):
workspace_id: str
name: str
owner_user_id: str | None = None
class Project(BaseModel):
project_id: str
workspace_id: str
name: str
class Task(BaseModel):
task_id: str
project_id: str
title: str
status: TaskStatus = TaskStatus.OPEN
assignee_user_id: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class ChangeSession(BaseModel):
session_id: str
task_id: str
user_id: str
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: datetime | None = None
class Comment(BaseModel):
comment_id: str
project_id: str
target_id: str
user_id: str
body: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class ActivityEvent(BaseModel):
event_id: str
project_id: str
actor_user_id: str
verb: str
target_id: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
attributes: dict = Field(default_factory=dict)
class Ownership(BaseModel):
owner_user_id: str
project_id: str
target_id: str
role: str = "OWNER"
assigned_by: str | None = None
assigned_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
attributes: dict = Field(default_factory=dict)
class InMemoryCollaborationStore:
def __init__(self) -> None:
self.users: dict[str, User] = {}
self.workspaces: dict[str, Workspace] = {}
self.projects: dict[str, Project] = {}
self.tasks: dict[str, Task] = {}
self.sessions: dict[str, ChangeSession] = {}
self.comments: dict[str, Comment] = {}
self.activities: list[ActivityEvent] = []
self.ownership: dict[str, Ownership] = {}
def upsert_user(self, user: User) -> User:
self.users[user.user_id] = user
return user
def upsert_workspace(self, workspace: Workspace) -> Workspace:
self.workspaces[workspace.workspace_id] = workspace
return workspace
def upsert_project(self, project: Project) -> Project:
self.projects[project.project_id] = project
return project
def upsert_task(self, task: Task) -> Task:
self.tasks[task.task_id] = task
return task
def start_session(self, session: ChangeSession) -> ChangeSession:
if session.task_id not in self.tasks:
raise KeyError(f"Unknown task: {session.task_id}")
if session.user_id not in self.users:
raise KeyError(f"Unknown user: {session.user_id}")
self.sessions[session.session_id] = session
return session
def finish_session(self, session_id: str) -> ChangeSession:
session = self.sessions.get(session_id)
if session is None:
raise KeyError(f"Unknown session: {session_id}")
if session.finished_at is None:
session = session.model_copy(update={"finished_at": datetime.now(timezone.utc)})
self.sessions[session_id] = session
return session
def add_comment(self, comment: Comment) -> Comment:
if comment.user_id not in self.users:
raise KeyError(f"Unknown user: {comment.user_id}")
self.comments[comment.comment_id] = comment
return comment
def comments_for_project(self, project_id: str) -> list[Comment]:
return sorted(
[
comment
for comment in self.comments.values()
if comment.project_id == project_id
],
key=lambda item: item.created_at,
)
def comments_for_target(self, project_id: str, target_id: str) -> list[Comment]:
return [
comment
for comment in self.comments_for_project(project_id)
if comment.target_id == target_id
]
def add_activity(self, event: ActivityEvent) -> ActivityEvent:
self.activities.append(event)
return event
def assign_owner(self, ownership: Ownership) -> Ownership:
if ownership.owner_user_id not in self.users:
raise KeyError(f"Unknown user: {ownership.owner_user_id}")
self.ownership[self._ownership_key(ownership)] = ownership
return ownership
def owners_for_project(self, project_id: str) -> list[Ownership]:
return sorted(
[
ownership
for ownership in self.ownership.values()
if ownership.project_id == project_id
],
key=lambda item: (item.target_id, item.role, item.owner_user_id),
)
def owners_for_target(self, project_id: str, target_id: str) -> list[Ownership]:
return [
ownership
for ownership in self.owners_for_project(project_id)
if ownership.target_id == target_id
]
def activity_feed(self, project_id: str) -> list[ActivityEvent]:
return [
event
for event in sorted(self.activities, key=lambda item: item.created_at, reverse=True)
if event.project_id == project_id
]
def _ownership_key(self, ownership: Ownership) -> str:
return f"{ownership.project_id}:{ownership.target_id}:{ownership.role}:{ownership.owner_user_id}"
__all__ = [
"ActivityEvent",
"ChangeSession",
"Comment",
"InMemoryCollaborationStore",
"Ownership",
"Project",
"Task",
"TaskStatus",
"User",
"Workspace",
]
@@ -0,0 +1,62 @@
from collaboration import ChangeSession, Comment, InMemoryCollaborationStore, Ownership, Task, User
def test_start_session_requires_known_task_and_user():
store = InMemoryCollaborationStore()
store.upsert_user(User(user_id="user.1", display_name="Tester"))
store.upsert_task(Task(task_id="task.1", project_id="demo", title="Index project"))
session = store.start_session(
ChangeSession(session_id="session.1", task_id="task.1", user_id="user.1")
)
assert session.session_id == "session.1"
def test_finish_session_sets_finished_at_once():
store = InMemoryCollaborationStore()
store.upsert_user(User(user_id="user.1", display_name="Tester"))
store.upsert_task(Task(task_id="task.1", project_id="demo", title="Index project"))
store.start_session(ChangeSession(session_id="session.1", task_id="task.1", user_id="user.1"))
finished = store.finish_session("session.1")
finished_again = store.finish_session("session.1")
assert finished.finished_at is not None
assert finished_again.finished_at == finished.finished_at
def test_assign_owner_is_project_and_target_scoped():
store = InMemoryCollaborationStore()
store.upsert_user(User(user_id="user.1", display_name="Tester"))
ownership = store.assign_owner(
Ownership(
owner_user_id="user.1",
project_id="demo",
target_id="lineage.document.order",
role="RESPONSIBLE",
)
)
assert ownership.owner_user_id == "user.1"
assert store.owners_for_project("demo") == [ownership]
assert store.owners_for_target("demo", "lineage.document.order") == [ownership]
def test_add_comment_is_project_and_target_scoped():
store = InMemoryCollaborationStore()
store.upsert_user(User(user_id="user.1", display_name="Tester"))
comment = store.add_comment(
Comment(
comment_id="comment.1",
project_id="demo",
target_id="lineage.document.order",
user_id="user.1",
body="Check posting rules.",
)
)
assert store.comments_for_project("demo") == [comment]
assert store.comments_for_target("demo", "lineage.document.order") == [comment]
+10
View File
@@ -0,0 +1,10 @@
# sfera-impact-engine
Deterministic impact analysis over projected SIR graphs.
Provides:
- routine callers/callees/query tables/writes;
- 1C object impact for root metadata objects;
- modules, routines, forms, commands, handlers, schema nodes, jobs, writes, query tables;
- role access grants included in object impact.
+13
View File
@@ -0,0 +1,13 @@
[project]
name = "sfera-impact-engine"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-integration-topology",
"sfera-projection-engine",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,262 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from integration_topology import IntegrationEndpoint, build_integration_topology
from projection_engine import InMemoryProjection
from sir import EdgeKind, NodeKind, SemanticNode, SirSnapshot
class RoutineImpactReport(BaseModel):
routine_name: str
callers: list[SemanticNode] = Field(default_factory=list)
callees: list[SemanticNode] = Field(default_factory=list)
query_tables: list[SemanticNode] = Field(default_factory=list)
writes: list[SemanticNode] = Field(default_factory=list)
class RoleAccessGrant(BaseModel):
role: SemanticNode
permissions: dict = Field(default_factory=dict)
class ObjectImpactReport(BaseModel):
object_name: str
object: SemanticNode
modules: list[SemanticNode] = Field(default_factory=list)
routines: list[SemanticNode] = Field(default_factory=list)
forms: list[SemanticNode] = Field(default_factory=list)
commands: list[SemanticNode] = Field(default_factory=list)
attributes: list[SemanticNode] = Field(default_factory=list)
tabular_sections: list[SemanticNode] = Field(default_factory=list)
tabular_section_columns: dict[str, list[SemanticNode]] = Field(default_factory=dict)
roles: list[SemanticNode] = Field(default_factory=list)
role_access: list[RoleAccessGrant] = Field(default_factory=list)
jobs: list[SemanticNode] = Field(default_factory=list)
callees: list[SemanticNode] = Field(default_factory=list)
query_tables: list[SemanticNode] = Field(default_factory=list)
writes: list[SemanticNode] = Field(default_factory=list)
integrations: list[IntegrationEndpoint] = Field(default_factory=list)
def routine_impact(graph: InMemoryProjection, routine_name: str) -> RoutineImpactReport:
return RoutineImpactReport(
routine_name=routine_name,
callers=graph.find_callers(routine_name),
callees=graph.find_callees(routine_name),
query_tables=graph.find_query_tables(routine_name),
writes=graph.find_writes(routine_name),
)
def object_impact(graph: InMemoryProjection, object_name: str) -> ObjectImpactReport | None:
owner = _find_object(graph, object_name)
if owner is None:
return None
modules = _targets(graph, owner, EdgeKind.CONTAINS, {NodeKind.MODULE})
attributes = _targets(graph, owner, EdgeKind.HAS_ATTRIBUTE, {NodeKind.ATTRIBUTE})
tabular_sections = _targets(graph, owner, EdgeKind.HAS_TABULAR_SECTION, {NodeKind.TABULAR_SECTION})
tabular_section_columns = {
section.lineage_id: _targets(graph, section, EdgeKind.HAS_ATTRIBUTE, {NodeKind.ATTRIBUTE})
for section in tabular_sections
}
forms = _targets(graph, owner, EdgeKind.HAS_FORM, {NodeKind.FORM})
commands = _dedupe_nodes(
[
*_targets(graph, owner, EdgeKind.HAS_COMMAND, {NodeKind.COMMAND}),
*[
command
for form in forms
for command in _targets(graph, form, EdgeKind.HAS_COMMAND, {NodeKind.COMMAND})
],
]
)
command_handlers = _dedupe_nodes(
[
handler
for command in commands
for handler in _targets(graph, command, EdgeKind.HANDLES, {NodeKind.PROCEDURE, NodeKind.FUNCTION})
]
)
form_handlers = _dedupe_nodes(
[
handler
for form in forms
for handler in _targets(graph, form, EdgeKind.HANDLES, {NodeKind.PROCEDURE, NodeKind.FUNCTION})
]
)
role_access = _role_access_grants(graph, owner)
roles = _dedupe_nodes([grant.role for grant in role_access])
job_routines = _targets(graph, owner, EdgeKind.RUNS, {NodeKind.PROCEDURE, NodeKind.FUNCTION})
routines = _dedupe_nodes(
job_routines
+ command_handlers
+ form_handlers
+ [
routine
for module in modules
for routine in _targets(graph, module, EdgeKind.DECLARES, {NodeKind.PROCEDURE, NodeKind.FUNCTION})
]
)
routine_modules = _dedupe_nodes(
[
module
for routine in routines
for module in _sources(graph, routine, EdgeKind.DECLARES, {NodeKind.MODULE})
]
)
modules = _dedupe_nodes([*modules, *routine_modules])
callees = _dedupe_nodes(
[
callee
for routine in routines
for callee in _targets(graph, routine, EdgeKind.CALLS, {NodeKind.PROCEDURE, NodeKind.FUNCTION})
]
)
query_nodes = [
query
for routine in routines
for query in _targets(graph, routine, EdgeKind.OWNS_QUERY, {NodeKind.QUERY})
]
query_tables = _dedupe_nodes(
[
table
for query in query_nodes
for table in _targets(graph, query, EdgeKind.READS_TABLE, {NodeKind.TABLE, NodeKind.REGISTER})
]
)
writes = _dedupe_nodes(
[
target
for routine in routines
for target in _targets(graph, routine, EdgeKind.WRITES, {NodeKind.REGISTER, NodeKind.TABLE})
]
)
return ObjectImpactReport(
object_name=object_name,
object=owner,
modules=modules,
routines=routines,
forms=forms,
commands=commands,
attributes=attributes,
tabular_sections=tabular_sections,
tabular_section_columns=tabular_section_columns,
roles=roles,
role_access=role_access,
jobs=[owner] if owner.kind == NodeKind.SCHEDULED_JOB else [],
callees=callees,
query_tables=query_tables,
writes=writes,
)
def object_impact_from_snapshot(snapshot: SirSnapshot, object_name: str) -> ObjectImpactReport | None:
graph = InMemoryProjection()
graph.project_snapshot(snapshot)
report = object_impact(graph, object_name)
if report is None:
return None
routine_names = {routine.qualified_name for routine in report.routines}
module_names = {module.qualified_name for module in report.modules}
report.integrations = [
endpoint
for endpoint in build_integration_topology(snapshot).endpoints
if endpoint.owner in routine_names or endpoint.owner in module_names
]
return report
def _find_object(graph: InMemoryProjection, object_name: str) -> SemanticNode | None:
wanted = object_name.lower()
object_kinds = {
NodeKind.CATALOG,
NodeKind.DOCUMENT,
NodeKind.CONSTANT,
NodeKind.DOCUMENT_JOURNAL,
NodeKind.ENUM,
NodeKind.REPORT,
NodeKind.DATA_PROCESSOR,
NodeKind.CHART_OF_CHARACTERISTIC_TYPES,
NodeKind.CHART_OF_ACCOUNTS,
NodeKind.CHART_OF_CALCULATION_TYPES,
NodeKind.REGISTER,
NodeKind.COMMON_MODULE,
NodeKind.EXCHANGE_PLAN,
NodeKind.EXTERNAL_DATA_SOURCE,
NodeKind.SCHEDULED_JOB,
NodeKind.BUSINESS_PROCESS,
NodeKind.TASK,
}
return next(
(
node
for node in graph.nodes.values()
if node.kind in object_kinds
and (node.name.lower() == wanted or node.qualified_name.lower() == wanted)
),
None,
)
def _targets(
graph: InMemoryProjection,
source: SemanticNode,
kind: EdgeKind,
target_kinds: set[NodeKind],
) -> list[SemanticNode]:
return [
graph.nodes[edge.target_lineage]
for edge in graph.edges.values()
if edge.kind == kind
and edge.source_lineage == source.lineage_id
and edge.target_lineage in graph.nodes
and graph.nodes[edge.target_lineage].kind in target_kinds
]
def _sources(
graph: InMemoryProjection,
target: SemanticNode,
kind: EdgeKind,
source_kinds: set[NodeKind],
) -> list[SemanticNode]:
return [
graph.nodes[edge.source_lineage]
for edge in graph.edges.values()
if edge.kind == kind
and edge.target_lineage == target.lineage_id
and edge.source_lineage in graph.nodes
and graph.nodes[edge.source_lineage].kind in source_kinds
]
def _role_access_grants(graph: InMemoryProjection, target: SemanticNode) -> list[RoleAccessGrant]:
grants: list[RoleAccessGrant] = []
for edge in graph.edges.values():
if edge.kind != EdgeKind.GRANTS_ACCESS or edge.target_lineage != target.lineage_id:
continue
role = graph.nodes.get(edge.source_lineage)
if role is None or role.kind != NodeKind.ROLE:
continue
grants.append(RoleAccessGrant(role=role, permissions=edge.attributes))
return grants
def _dedupe_nodes(nodes: list[SemanticNode]) -> list[SemanticNode]:
seen: dict[str, SemanticNode] = {}
for node in nodes:
seen.setdefault(node.lineage_id, node)
return list(seen.values())
__all__ = [
"ObjectImpactReport",
"RoleAccessGrant",
"RoutineImpactReport",
"object_impact",
"object_impact_from_snapshot",
"routine_impact",
]
+190
View File
@@ -0,0 +1,190 @@
from pathlib import Path
from impact_engine import object_impact, object_impact_from_snapshot, routine_impact
from projection_engine import InMemoryProjection
from semantic_kernel import index_project
def test_routine_impact_uses_projection(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
graph = InMemoryProjection()
graph.project_snapshot(index_project(tmp_path, project_id="demo"))
impact = routine_impact(graph, "Проведение")
assert [node.name for node in impact.callees] == ["ПроверитьОстатки"]
def test_object_impact_collects_1c_metadata_module_routines_and_writes(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" action="ПровестиКоманда" />
</Form>
</Document>
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
<Right object="Документ.ЗаказПокупателя" read="true" write="true" post="true" />
</Role>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
Процедура ПровестиКоманда()
КонецПроцедуры
""",
encoding="utf-8",
)
graph = InMemoryProjection()
graph.project_snapshot(index_project(tmp_path, project_id="object-impact"))
impact = object_impact(graph, "Документ.ЗаказПокупателя")
assert impact is not None
assert impact.object.name == "ЗаказПокупателя"
assert [node.name for node in impact.modules] == ["ObjectModule"]
assert [node.name for node in impact.routines] == ["ПровестиКоманда", "Проведение", "ПроверитьОстатки"]
assert [node.name for node in impact.forms] == ["ФормаДокумента"]
assert [node.name for node in impact.commands] == ["Провести"]
assert [node.name for node in impact.attributes] == ["Контрагент"]
assert [node.name for node in impact.tabular_sections] == ["Товары"]
goods = impact.tabular_sections[0]
assert [node.name for node in impact.tabular_section_columns[goods.lineage_id]] == ["Номенклатура"]
assert [node.name for node in impact.roles] == ["Менеджер"]
assert impact.role_access[0].permissions["post"] == "true"
assert [node.name for node in impact.callees] == ["ПроверитьОстатки"]
assert [node.name for node in impact.writes] == ["ОстаткиТоваров"]
def test_object_impact_collects_form_event_handler_dependencies(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Form
name="ФормаДокумента"
qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента"
beforeWrite="ПередЗаписью"
/>
</Document>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text(
"""
Процедура ПередЗаписью()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
graph = InMemoryProjection()
graph.project_snapshot(index_project(tmp_path, project_id="form-event-impact"))
impact = object_impact(graph, "Документ.ЗаказПокупателя")
assert impact is not None
assert [node.name for node in impact.routines] == ["ПередЗаписью"]
assert [node.name for node in impact.writes] == ["ОстаткиТоваров"]
def test_object_impact_collects_scheduled_job_routine(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<ScheduledJob name="ОбновлениеЦен" qualifiedName="РегламентноеЗадание.ОбновлениеЦен" method="ОбновитьЦены" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "CommonModules" / "РегламентныеОперации" / "Module.bsl"
module.parent.mkdir(parents=True)
module.write_text("Процедура ОбновитьЦены()\nКонецПроцедуры\n", encoding="utf-8")
graph = InMemoryProjection()
graph.project_snapshot(index_project(tmp_path, project_id="job-impact"))
impact = object_impact(graph, "РегламентноеЗадание.ОбновлениеЦен")
assert impact is not None
assert [node.name for node in impact.jobs] == ["ОбновлениеЦен"]
assert [node.name for node in impact.routines] == ["ОбновитьЦены"]
def test_object_impact_from_snapshot_collects_scheduled_job_integrations(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<ScheduledJob name="ОтправкаЗаказов" qualifiedName="РегламентноеЗадание.ОтправкаЗаказов" method="ОтправитьЗаказы" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "CommonModules" / "Интеграция" / "Module.bsl"
module.parent.mkdir(parents=True)
module.write_text(
"""
Процедура ОтправитьЗаказы()
Адрес = "https://api.example.local/orders";
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="job-integration-impact")
impact = object_impact_from_snapshot(snapshot, "РегламентноеЗадание.ОтправкаЗаказов")
assert impact is not None
assert [node.name for node in impact.routines] == ["ОтправитьЗаказы"]
assert [endpoint.name for endpoint in impact.integrations] == ["https://api.example.local/orders"]
def test_object_impact_supports_report_metadata_owner(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Report name="Продажи" qualifiedName="Отчет.Продажи">
<Attribute name="Период" qualifiedName="Отчет.Продажи.Период" />
</Report>
</Configuration>
""",
encoding="utf-8",
)
graph = InMemoryProjection()
graph.project_snapshot(index_project(tmp_path, project_id="report-impact"))
impact = object_impact(graph, "Отчет.Продажи")
assert impact is not None
assert impact.object.name == "Продажи"
assert [node.name for node in impact.attributes] == ["Период"]
+11
View File
@@ -0,0 +1,11 @@
# sfera-incremental-indexer
Incremental SIR snapshot updater.
Provides:
- source hash collection;
- changed `.bsl` / `.xml` file detection;
- changed-file rebuild;
- project rebuild when metadata links can be affected;
- `SirDelta` construction for snapshot transition and projection updates.
@@ -0,0 +1,11 @@
[project]
name = "sfera-incremental-indexer"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"sfera-semantic-kernel",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,152 @@
from __future__ import annotations
from pathlib import Path
import os
from semantic_kernel import index_project
from sir import SirDelta, SirSnapshot, compute_snapshot_hash, validate_snapshot
def source_hashes(snapshot: SirSnapshot) -> dict[str, str]:
result: dict[str, str] = {}
for node in snapshot.nodes:
if node.source_ref.source_hash:
result[node.source_ref.source_path] = node.source_ref.source_hash
return result
def changed_bsl_files(root: str | Path, snapshot: SirSnapshot) -> list[Path]:
return changed_source_files(root, snapshot, suffixes={".bsl"})
def changed_source_files(
root: str | Path,
snapshot: SirSnapshot,
*,
suffixes: set[str] | None = None,
) -> list[Path]:
base = Path(root)
known_hashes = source_hashes(snapshot)
changed: list[Path] = []
wanted_suffixes = {suffix.lower() for suffix in (suffixes or {".bsl", ".xml"})}
for path in sorted(candidate for candidate in base.rglob("*") if candidate.suffix.lower() in wanted_suffixes):
text = path.read_text(encoding="utf-8")
new_snapshot = index_project(path, project_id=snapshot.project_id)
new_hash = next(iter(source_hashes(new_snapshot).values()), None)
if new_hash != known_hashes.get(path.as_posix()):
changed.append(path)
return changed
def rebuild_changed_file(previous: SirSnapshot, changed_file: str | Path) -> tuple[SirSnapshot, SirDelta]:
path = Path(changed_file)
project_root = _project_root(previous, path)
if project_root is not None and project_root.is_dir():
return rebuild_project(previous, project_root)
return _rebuild_changed_file_fragment(previous, path)
def rebuild_project(previous: SirSnapshot, project_root: str | Path) -> tuple[SirSnapshot, SirDelta]:
current = index_project(project_root, project_id=previous.project_id)
current.snapshot_id = previous.snapshot_id
current.revision = previous.revision
current.metadata.task_id = previous.metadata.task_id
current.metadata.session_id = previous.metadata.session_id
current.snapshot_hash = compute_snapshot_hash(current)
validate_snapshot(current)
return current, build_delta(previous, current)
def _rebuild_changed_file_fragment(previous: SirSnapshot, changed_file: Path) -> tuple[SirSnapshot, SirDelta]:
path = changed_file
fragment = index_project(path, project_id=previous.project_id)
changed_source = path.as_posix()
old_fragment_lineages = {
node.lineage_id for node in previous.nodes if node.source_ref.source_path == changed_source
}
next_fragment_lineages = {node.lineage_id for node in fragment.nodes}
old_nodes = [node for node in previous.nodes if node.source_ref.source_path != changed_source]
next_lineages = {node.lineage_id for node in old_nodes} | next_fragment_lineages
old_edges = [
edge
for edge in previous.edges
if (edge.source_ref is None or edge.source_ref.source_path != changed_source)
and edge.source_lineage not in old_fragment_lineages
and edge.target_lineage in next_lineages
]
next_snapshot = SirSnapshot(
snapshot_id=previous.snapshot_id,
project_id=previous.project_id,
revision=previous.revision,
metadata=previous.metadata,
nodes=[*old_nodes, *fragment.nodes],
edges=[*old_edges, *fragment.edges],
diagnostics=[
diagnostic
for diagnostic in previous.diagnostics
if diagnostic.source_ref is None or diagnostic.source_ref.source_path != path.as_posix()
]
+ fragment.diagnostics,
)
next_snapshot.snapshot_hash = compute_snapshot_hash(next_snapshot)
validate_snapshot(next_snapshot)
return next_snapshot, build_delta(previous, next_snapshot)
def _project_root(previous: SirSnapshot, changed_file: Path) -> Path | None:
if previous.metadata.source_root:
return Path(previous.metadata.source_root)
paths = [Path(path) for path in source_hashes(previous)]
if not paths:
return changed_file.parent if changed_file.parent.exists() else None
try:
common = Path(os.path.commonpath([path.as_posix() for path in [*paths, changed_file]]))
return common if common.is_dir() else common.parent
except ValueError:
return None
def build_delta(previous: SirSnapshot, current: SirSnapshot) -> SirDelta:
previous_nodes = {node.lineage_id: node for node in previous.nodes}
current_nodes = {node.lineage_id: node for node in current.nodes}
previous_edges = {edge.edge_id: edge for edge in previous.edges}
current_edges = {edge.edge_id: edge for edge in current.edges}
added_nodes = [
node for lineage_id, node in current_nodes.items() if lineage_id not in previous_nodes
]
updated_nodes = [
node
for lineage_id, node in current_nodes.items()
if lineage_id in previous_nodes and node != previous_nodes[lineage_id]
]
removed_nodes = [
lineage_id for lineage_id in previous_nodes if lineage_id not in current_nodes
]
added_edges = [
edge for edge_id, edge in current_edges.items() if edge_id not in previous_edges
]
removed_edges = [
edge_id for edge_id in previous_edges if edge_id not in current_edges
]
return SirDelta(
delta_id=f"delta.{previous.snapshot_id}.{current.snapshot_hash}",
snapshot_from=previous.snapshot_id,
snapshot_to=current.snapshot_id,
added_nodes=added_nodes,
updated_nodes=updated_nodes,
removed_nodes=removed_nodes,
added_edges=added_edges,
removed_edges=removed_edges,
)
__all__ = [
"build_delta",
"changed_bsl_files",
"changed_source_files",
"rebuild_changed_file",
"rebuild_project",
"source_hashes",
]
@@ -0,0 +1,121 @@
from pathlib import Path
from incremental_indexer import rebuild_changed_file
from semantic_kernel import index_project
from sir import EdgeKind, NodeKind
def test_rebuild_changed_file_returns_delta(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
previous = index_project(tmp_path, project_id="demo")
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
current, delta = rebuild_changed_file(previous, module)
assert current.snapshot_hash != previous.snapshot_hash
assert delta.added_edges
assert any(node.name == "ОстаткиТоваров" for node in delta.added_nodes)
def test_rebuild_changed_xml_rebuilds_project_metadata_links(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text("Процедура Проведение()\nКонецПроцедуры\n", encoding="utf-8")
previous = index_project(tmp_path, project_id="xml-incremental")
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" />
</Form>
</Document>
</Configuration>
""",
encoding="utf-8",
)
current, delta = rebuild_changed_file(previous, xml)
assert current.snapshot_hash != previous.snapshot_hash
assert any(node.kind == NodeKind.FORM and node.name == "ФормаДокумента" for node in delta.added_nodes)
assert any(node.kind == NodeKind.COMMAND and node.name == "Провести" for node in delta.added_nodes)
assert any(edge.kind == EdgeKind.CONTAINS for edge in current.edges)
assert any(edge.kind == EdgeKind.HAS_FORM for edge in current.edges)
def test_rebuild_changed_bsl_preserves_metadata_owner_link(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text("Процедура Проведение()\nКонецПроцедуры\n", encoding="utf-8")
previous = index_project(tmp_path, project_id="bsl-incremental")
module.write_text(
"""
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
current, delta = rebuild_changed_file(previous, module)
assert any(node.kind == NodeKind.REGISTER and node.name == "ОстаткиТоваров" for node in delta.added_nodes)
assert any(edge.kind == EdgeKind.CONTAINS for edge in current.edges)
assert any(edge.kind == EdgeKind.WRITES for edge in delta.added_edges)
def test_rebuild_changed_file_refreshes_diagnostics(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text("Процедура Проведение()\nКонецПроцедуры\n", encoding="utf-8")
previous = index_project(tmp_path, project_id="diagnostics-incremental")
module.write_text("Процедура Проведение()\n", encoding="utf-8")
current, delta = rebuild_changed_file(previous, module)
assert any(diagnostic.code == "BSL_UNCLOSED_ROUTINE" for diagnostic in current.diagnostics)
assert delta.updated_nodes or delta.removed_nodes or current.snapshot_hash != previous.snapshot_hash
+3
View File
@@ -0,0 +1,3 @@
# sfera-integration-topology
Integration endpoint inventory stored as semantic nodes and dependencies.
@@ -0,0 +1,11 @@
[project]
name = "sfera-integration-topology"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,128 @@
from __future__ import annotations
from enum import Enum
import re
from pydantic import BaseModel, Field
from sir import EdgeKind, NodeKind, SirSnapshot
class IntegrationKind(str, Enum):
HTTP_SERVICE = "HTTP_SERVICE"
WEB_SERVICE = "WEB_SERVICE"
EXCHANGE_PLAN = "EXCHANGE_PLAN"
FILE_EXCHANGE = "FILE_EXCHANGE"
COM_CONNECTOR = "COM_CONNECTOR"
UNKNOWN = "UNKNOWN"
class IntegrationEndpoint(BaseModel):
endpoint_id: str
name: str
kind: IntegrationKind = IntegrationKind.UNKNOWN
direction: str = "UNKNOWN"
owner: str | None = None
attributes: dict = Field(default_factory=dict)
class IntegrationTopology(BaseModel):
project_id: str
endpoints: list[IntegrationEndpoint] = Field(default_factory=list)
def by_kind(self, kind: IntegrationKind) -> list[IntegrationEndpoint]:
return [endpoint for endpoint in self.endpoints if endpoint.kind == kind]
_URL_RE = re.compile(r"https?://[^\"'\s;]+", re.IGNORECASE)
def build_integration_topology(snapshot: SirSnapshot) -> IntegrationTopology:
endpoints: list[IntegrationEndpoint] = []
nodes = {node.lineage_id: node for node in snapshot.nodes}
graph_endpoint_lineages = set()
for edge in snapshot.edges:
if edge.kind != EdgeKind.USES_INTEGRATION or edge.target_lineage not in nodes:
continue
endpoint_node = nodes[edge.target_lineage]
if endpoint_node.kind != NodeKind.INTEGRATION_ENDPOINT:
continue
owner = nodes.get(edge.source_lineage)
graph_endpoint_lineages.add(endpoint_node.lineage_id)
endpoints.append(
IntegrationEndpoint(
endpoint_id=f"integration.{endpoint_node.lineage_id}",
name=endpoint_node.name,
kind=IntegrationKind(endpoint_node.attributes.get("integration_kind", "UNKNOWN")),
direction=str(endpoint_node.attributes.get("direction", "UNKNOWN")),
owner=owner.qualified_name if owner is not None else None,
attributes=endpoint_node.attributes,
)
)
for node in snapshot.nodes:
if node.kind == NodeKind.MODULE and not graph_endpoint_lineages:
endpoints.extend(_module_integrations(node))
elif node.kind == NodeKind.EXCHANGE_PLAN:
endpoints.append(
IntegrationEndpoint(
endpoint_id=f"integration.{node.lineage_id}",
name=node.name,
kind=IntegrationKind.EXCHANGE_PLAN,
direction="BIDIRECTIONAL",
owner=node.qualified_name,
attributes={"qualified_name": node.qualified_name},
)
)
return IntegrationTopology(project_id=snapshot.project_id, endpoints=_dedupe_endpoints(endpoints))
def _module_integrations(node) -> list[IntegrationEndpoint]:
text = str(node.attributes.get("source_text", ""))
if not text:
return []
endpoints: list[IntegrationEndpoint] = []
for index, url in enumerate(_URL_RE.findall(text), start=1):
endpoints.append(
IntegrationEndpoint(
endpoint_id=f"integration.{node.lineage_id}.url.{index}",
name=url,
kind=IntegrationKind.HTTP_SERVICE,
direction="OUTBOUND",
owner=node.qualified_name,
attributes={"url": url},
)
)
if "HTTPСоединение" in text or "HTTPConnection" in text:
endpoints.append(_code_endpoint(node, "HTTPConnection", IntegrationKind.HTTP_SERVICE, "OUTBOUND"))
if "WSПрокси" in text or "WSProxy" in text or "WSСсылка" in text:
endpoints.append(_code_endpoint(node, "WSProxy", IntegrationKind.WEB_SERVICE, "OUTBOUND"))
if "FTPСоединение" in text or "FTPConnection" in text:
endpoints.append(_code_endpoint(node, "FTPConnection", IntegrationKind.FILE_EXCHANGE, "OUTBOUND"))
if "COMОбъект" in text or "COMObject" in text:
endpoints.append(_code_endpoint(node, "COMObject", IntegrationKind.COM_CONNECTOR, "OUTBOUND"))
return endpoints
def _code_endpoint(node, name: str, kind: IntegrationKind, direction: str) -> IntegrationEndpoint:
return IntegrationEndpoint(
endpoint_id=f"integration.{node.lineage_id}.{name.casefold()}",
name=name,
kind=kind,
direction=direction,
owner=node.qualified_name,
)
def _dedupe_endpoints(endpoints: list[IntegrationEndpoint]) -> list[IntegrationEndpoint]:
seen: dict[tuple[str, str, str | None], IntegrationEndpoint] = {}
for endpoint in endpoints:
seen.setdefault((endpoint.name, endpoint.kind.value, endpoint.owner), endpoint)
return sorted(seen.values(), key=lambda endpoint: (endpoint.kind.value, endpoint.name))
__all__ = [
"IntegrationEndpoint",
"IntegrationKind",
"IntegrationTopology",
"build_integration_topology",
]
@@ -0,0 +1,47 @@
from integration_topology import IntegrationEndpoint, IntegrationKind, IntegrationTopology
from integration_topology import build_integration_topology
from semantic_kernel import index_project
def test_integration_topology_filters_by_kind():
topology = IntegrationTopology(
project_id="demo",
endpoints=[
IntegrationEndpoint(
endpoint_id="endpoint.1",
name="OrdersApi",
kind=IntegrationKind.HTTP_SERVICE,
)
],
)
assert topology.by_kind(IntegrationKind.HTTP_SERVICE)[0].name == "OrdersApi"
def test_build_integration_topology_from_bsl_and_exchange_plan(tmp_path):
module = tmp_path / "integration.bsl"
module.write_text(
"""
Процедура ОтправитьЗаказ()
Соединение = Новый HTTPСоединение("api.example.local");
Адрес = "https://api.example.local/orders";
Объект = Новый COMОбъект("V83.Application");
КонецПроцедуры
""",
encoding="utf-8",
)
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<ExchangePlan name="ОбменСКассой" qualifiedName="ПланОбмена.ОбменСКассой" />
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="integrations")
topology = build_integration_topology(snapshot)
assert topology.by_kind(IntegrationKind.HTTP_SERVICE)
assert topology.by_kind(IntegrationKind.COM_CONNECTOR)[0].name == "COMObject"
assert topology.by_kind(IntegrationKind.EXCHANGE_PLAN)[0].name == "ОбменСКассой"
+3
View File
@@ -0,0 +1,3 @@
# sfera-job-topology
Scheduled job inventory and links to semantic routines.
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-job-topology"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,70 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from sir import EdgeKind, NodeKind, SemanticNode, SirSnapshot
class ScheduledJob(BaseModel):
job_id: str
name: str
routine_name: str
schedule: str | None = None
attributes: dict = Field(default_factory=dict)
class JobBinding(BaseModel):
job: ScheduledJob
routine: SemanticNode | None = None
def bind_jobs(snapshot: SirSnapshot, jobs: list[ScheduledJob]) -> list[JobBinding]:
routines = {
node.name.casefold(): node
for node in snapshot.nodes
if node.kind.value in {"PROCEDURE", "FUNCTION"}
}
return [
JobBinding(job=job, routine=routines.get(job.routine_name.casefold()))
for job in sorted(jobs, key=lambda item: item.name)
]
def snapshot_scheduled_jobs(snapshot: SirSnapshot) -> list[JobBinding]:
nodes = {node.lineage_id: node for node in snapshot.nodes}
bindings: list[JobBinding] = []
for job_node in sorted(
(node for node in snapshot.nodes if node.kind == NodeKind.SCHEDULED_JOB),
key=lambda node: node.qualified_name,
):
run_edge = next(
(
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.RUNS and edge.source_lineage == job_node.lineage_id
),
None,
)
routine = nodes.get(run_edge.target_lineage) if run_edge is not None else None
routine_name = (
routine.name
if routine is not None
else str(job_node.attributes.get("method") or job_node.attributes.get("Method") or "")
)
bindings.append(
JobBinding(
job=ScheduledJob(
job_id=job_node.lineage_id,
name=job_node.name,
routine_name=routine_name,
schedule=str(job_node.attributes.get("schedule") or job_node.attributes.get("Schedule") or "")
or None,
attributes=job_node.attributes,
),
routine=routine,
)
)
return bindings
__all__ = ["JobBinding", "ScheduledJob", "bind_jobs", "snapshot_scheduled_jobs"]
@@ -0,0 +1,43 @@
from pathlib import Path
from job_topology import ScheduledJob, bind_jobs, snapshot_scheduled_jobs
from sir import EdgeKind
from semantic_kernel import index_project
def test_bind_jobs_to_routines(tmp_path: Path):
module = tmp_path / "jobs.bsl"
module.write_text("Процедура ОбновитьЦены()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="demo")
bindings = bind_jobs(
snapshot,
[ScheduledJob(job_id="job.1", name="Обновление цен", routine_name="ОбновитьЦены")],
)
assert bindings[0].routine is not None
assert bindings[0].routine.name == "ОбновитьЦены"
def test_snapshot_scheduled_jobs_bind_xml_job_to_routine(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<ScheduledJob name="ОбновлениеЦен" qualifiedName="РегламентноеЗадание.ОбновлениеЦен" method="ОбновитьЦены" schedule="КаждыйДень" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "CommonModules" / "РегламентныеОперации" / "Module.bsl"
module.parent.mkdir(parents=True)
module.write_text("Процедура ОбновитьЦены()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="jobs")
bindings = snapshot_scheduled_jobs(snapshot)
assert any(edge.kind == EdgeKind.RUNS for edge in snapshot.edges)
assert bindings[0].job.name == "ОбновлениеЦен"
assert bindings[0].job.schedule == "КаждыйДень"
assert bindings[0].routine is not None
assert bindings[0].routine.name == "ОбновитьЦены"
+10
View File
@@ -0,0 +1,10 @@
# sfera-knowledge-base
Deterministic project knowledge store.
Provides:
- scoped knowledge records;
- knowledge pack import with pack/vendor enrichment;
- search over title, body, tags, related lineages, and attributes;
- SIR lineage coverage reporting.
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-knowledge-base"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,169 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from collections.abc import Iterable
from pydantic import BaseModel, Field
from sir import SemanticNode, SirSnapshot
class KnowledgeScope(str, Enum):
GLOBAL = "GLOBAL"
WORKSPACE = "WORKSPACE"
PROJECT = "PROJECT"
SESSION = "SESSION"
class KnowledgeRecord(BaseModel):
record_id: str
scope: KnowledgeScope
title: str
body: str
tags: list[str] = Field(default_factory=list)
related_lineages: list[str] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
attributes: dict = Field(default_factory=dict)
class KnowledgePack(BaseModel):
pack_id: str
name: str
vendor: str | None = None
version: str | None = None
description: str = ""
records: list[KnowledgeRecord] = Field(default_factory=list)
attributes: dict = Field(default_factory=dict)
class KnowledgeSearchResult(BaseModel):
record: KnowledgeRecord
score: float
matched_fields: list[str]
class KnowledgeCoverageItem(BaseModel):
node: SemanticNode
record_count: int
class InMemoryKnowledgeBase:
def __init__(self) -> None:
self._records: dict[str, KnowledgeRecord] = {}
self._packs: dict[str, KnowledgePack] = {}
def upsert(self, record: KnowledgeRecord) -> KnowledgeRecord:
self._records[record.record_id] = record
return record
def import_pack(self, pack: KnowledgePack) -> KnowledgePack:
self._packs[pack.pack_id] = pack
for record in pack.records:
tags = sorted({*record.tags, f"pack:{pack.pack_id}", *(["vendor:" + pack.vendor] if pack.vendor else [])})
attributes = {
**record.attributes,
"pack_id": pack.pack_id,
"pack_name": pack.name,
"pack_version": pack.version,
"vendor": pack.vendor,
}
self.upsert(record.model_copy(update={"tags": tags, "attributes": attributes}))
return pack
def list_packs(self) -> list[KnowledgePack]:
return sorted(self._packs.values(), key=lambda pack: (pack.vendor or "", pack.name, pack.version or ""))
def get(self, record_id: str) -> KnowledgeRecord | None:
return self._records.get(record_id)
def list_records(self, scope: KnowledgeScope | None = None) -> list[KnowledgeRecord]:
records = list(self._records.values())
if scope is not None:
records = [record for record in records if record.scope == scope]
return sorted(records, key=lambda record: (record.scope.value, record.title))
def search(
self,
query: str,
*,
scope: KnowledgeScope | None = None,
limit: int = 20,
) -> list[KnowledgeSearchResult]:
normalized = query.casefold().strip()
if not normalized:
return []
results: list[KnowledgeSearchResult] = []
for record in self.list_records(scope):
score, fields = _score_record(record, normalized)
if score > 0:
results.append(KnowledgeSearchResult(record=record, score=score, matched_fields=fields))
results.sort(key=lambda item: (-item.score, -item.record.created_at.timestamp(), item.record.title))
return results[:limit]
def coverage(self, snapshot: SirSnapshot) -> list[KnowledgeCoverageItem]:
counts: dict[str, int] = {node.lineage_id: 0 for node in snapshot.nodes}
for record in self._records.values():
for lineage_id in record.related_lineages:
if lineage_id in counts:
counts[lineage_id] += 1
return [
KnowledgeCoverageItem(node=node, record_count=counts[node.lineage_id])
for node in sorted(snapshot.nodes, key=lambda item: item.qualified_name)
]
def _score_record(record: KnowledgeRecord, query: str) -> tuple[float, list[str]]:
fields = {
"title": record.title,
"body": record.body,
"tags": " ".join(record.tags),
"related_lineages": " ".join(record.related_lineages),
}
score = 0.0
matched: list[str] = []
for field, value in fields.items():
field_score = _score_text(value, query)
if field_score:
score += field_score
matched.append(field)
for field, value in _attribute_search_fields(record.attributes):
field_score = _score_text(value, query)
if field_score:
score += max(field_score - 1.0, 1.0)
matched.append(field)
return score, matched
def _score_text(value: object, query: str) -> float:
normalized = str(value).casefold()
if normalized == query:
return 10.0
if normalized.startswith(query):
return 5.0
if query in normalized:
return 2.0
return 0.0
def _attribute_search_fields(attributes: dict) -> Iterable[tuple[str, object]]:
for key, value in sorted(attributes.items()):
field = f"attributes.{key}"
if isinstance(value, dict):
for nested_key, nested_value in _attribute_search_fields(value):
yield f"{field}.{nested_key.removeprefix('attributes.')}", nested_value
elif isinstance(value, list):
for index, item in enumerate(value):
yield f"{field}[{index}]", item
else:
yield field, value
__all__ = [
"InMemoryKnowledgeBase",
"KnowledgeCoverageItem",
"KnowledgePack",
"KnowledgeRecord",
"KnowledgeScope",
"KnowledgeSearchResult",
]
@@ -0,0 +1,75 @@
from pathlib import Path
from knowledge_base import InMemoryKnowledgeBase, KnowledgePack, KnowledgeRecord, KnowledgeScope
from semantic_kernel import index_project
def test_knowledge_search_and_coverage(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text("Процедура Проведение()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="demo")
routine = next(node for node in snapshot.nodes if node.name == "Проведение")
kb = InMemoryKnowledgeBase()
kb.upsert(
KnowledgeRecord(
record_id="knowledge.1",
scope=KnowledgeScope.PROJECT,
title="Правила проведения",
body="Проверки документа перед записью движений.",
tags=["posting"],
related_lineages=[routine.lineage_id],
)
)
assert kb.search("проведения")[0].record.record_id == "knowledge.1"
covered = [item for item in kb.coverage(snapshot) if item.node.lineage_id == routine.lineage_id]
assert covered[0].record_count == 1
def test_knowledge_pack_import_adds_pack_metadata():
kb = InMemoryKnowledgeBase()
pack = KnowledgePack(
pack_id="bsp.core",
name="BSP Core",
vendor="1C",
version="3.1",
records=[
KnowledgeRecord(
record_id="knowledge.bsp.roles",
scope=KnowledgeScope.GLOBAL,
title="БСП роли",
body="Рекомендации по ролям БСП.",
)
],
)
stored = kb.import_pack(pack)
assert kb.list_packs() == [stored]
record = kb.get("knowledge.bsp.roles")
assert record is not None
assert "pack:bsp.core" in record.tags
assert "vendor:1C" in record.tags
assert record.attributes["pack_version"] == "3.1"
def test_knowledge_search_matches_pack_attributes_and_lineages():
kb = InMemoryKnowledgeBase()
kb.upsert(
KnowledgeRecord(
record_id="knowledge.vendor.rule",
scope=KnowledgeScope.PROJECT,
title="Rules",
body="Document checks.",
related_lineages=["lineage.document.order"],
attributes={"vendor": "1C", "pack_id": "bsp.core"},
)
)
by_vendor = kb.search("1C")
by_lineage = kb.search("lineage.document.order")
assert by_vendor[0].record.record_id == "knowledge.vendor.rule"
assert "attributes.vendor" in by_vendor[0].matched_fields
assert by_lineage[0].record.record_id == "knowledge.vendor.rule"
assert "related_lineages" in by_lineage[0].matched_fields
+11
View File
@@ -0,0 +1,11 @@
# sfera-one-c-normalizer
Нормализация исходников и метаданных 1С для SFERA.
Текущий пакет содержит:
- нормализацию BSL-файлов;
- первичный парсер XML-выгрузок метаданных;
- каталог типов метаданных 1С для построения дерева SFERA IDE.
Опорная модель дерева описана в `docs/1c-metadata-structure.md`.
+10
View File
@@ -0,0 +1,10 @@
[project]
name = "sfera-one-c-normalizer"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
]
[tool.uv]
package = true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,415 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class MetadataTypeSpec:
code: str
russian_name: str
tree_branch: str
icon: str
child_groups: tuple[str, ...] = ()
module_kinds: tuple[str, ...] = ()
properties: tuple[str, ...] = ()
context_actions: tuple[str, ...] = ()
@dataclass(frozen=True)
class MetadataChildObjectSpec:
code: str
russian_name: str
parent_groups: tuple[str, ...]
description: str
documentation_url: str = "https://edt.1c.ru/dev/edt/2024.2/apidocs/com/_1c/g5/v8/dt/metadata/mdclass/package-summary.html"
COMMON_BRANCH_CHILDREN = (
"Подсистемы",
"Общие модули",
"Параметры сеанса",
"Роли",
"Общие реквизиты",
"Планы обмена",
"Подписки на события",
"Критерии отбора",
"Регламентные задания",
"Функциональные опции",
"Параметры функциональных опций",
"Определяемые типы",
"Хранилища настроек",
"Общие команды",
"Группы команд",
"Общие формы",
"Общие макеты",
"Общие картинки",
"XDTO-пакеты",
"Web-сервисы",
"HTTP-сервисы",
"WS-ссылки",
"WebSocket-клиенты",
"Сервисы интеграции",
"Боты",
"Интерфейсы",
"Словари полнотекстового поиска",
"Цвета палитры",
"Элементы стиля",
"Стили",
"Языки",
)
OBJECT_MODULES = ("Модуль объекта", "Модуль менеджера")
RECORD_SET_MODULES = ("Модуль набора записей", "Модуль менеджера")
MANAGER_MODULE = ("Модуль менеджера",)
HANDLER_METHOD = ("Обработчик",)
STANDARD_PROPERTIES = (
"Имя",
"Синоним",
"Комментарий",
"Подсистемы",
"Функциональные опции",
)
DATA_OBJECT_PROPERTIES = STANDARD_PROPERTIES + (
"Основная форма",
"Форма списка",
"Форма выбора",
"Представление объекта",
"Представление списка",
)
REFERENCE_OBJECT_PROPERTIES = DATA_OBJECT_PROPERTIES + (
"Иерархический",
"Владельцы",
"Длина кода",
"Длина наименования",
"Уникальность кода",
)
DOCUMENT_PROPERTIES = DATA_OBJECT_PROPERTIES + (
"Нумерация",
"Проведение",
"Оперативное проведение",
"Запись движений",
"Журналирование",
)
REGISTER_PROPERTIES = STANDARD_PROPERTIES + (
"Периодичность",
"Режим записи",
"Основной отбор",
"Итоги",
)
MODULE_CONTEXT_ACTIONS = (
"Открыть модуль",
"Открыть менеджер",
"Найти вызовы",
"Найти использование",
"Показать влияние",
)
OBJECT_CONTEXT_ACTIONS = (
"Открыть объект",
"Открыть модуль объекта",
"Открыть модуль менеджера",
"Показать связи",
"Найти ссылки",
"Показать права",
)
RECORD_SET_CONTEXT_ACTIONS = (
"Открыть набор записей",
"Открыть модуль набора записей",
"Открыть модуль менеджера",
"Показать чтение/запись",
"Найти ссылки",
)
STRUCTURED_OBJECT_CHILDREN = (
"Реквизиты",
"Табличные части",
"Формы",
"Команды",
"Макеты",
"Права",
)
REGISTER_CHILDREN = ("Измерения", "Ресурсы", "Реквизиты", "Формы", "Команды", "Макеты", "Права")
REPORT_CHILDREN = (
"Реквизиты",
"Табличные части",
"Формы",
"Команды",
"Макеты",
"Табличные документы",
"СКД",
"Варианты отчета",
"Настройки",
"Хранилище вариантов",
"Хранилище настроек",
"Справка",
"Права",
)
METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
MetadataTypeSpec("COMMON", "Общие", "Общие", "common", COMMON_BRANCH_CHILDREN),
MetadataTypeSpec(
"COMMON_MODULE",
"Общий модуль",
"Общие модули",
"module",
("Процедуры", "Функции", "Экспортные методы", "Вызовы", "Кто вызывает", "Запросы", "Записи", "Проверки", "Версии", "Знания"),
("Модуль",),
),
MetadataTypeSpec("CONSTANT", "Константа", "Константы", "constant", ("Формы", "Команды", "Права"), MANAGER_MODULE),
MetadataTypeSpec(
"CATALOG",
"Справочник",
"Справочники",
"catalog",
STRUCTURED_OBJECT_CHILDREN + ("Предопределенные данные",),
OBJECT_MODULES,
),
MetadataTypeSpec(
"DOCUMENT",
"Документ",
"Документы",
"document",
STRUCTURED_OBJECT_CHILDREN + ("Движения", "Последовательности", "Нумераторы"),
OBJECT_MODULES,
),
MetadataTypeSpec("DOCUMENT_JOURNAL", "Журнал документов", "Журналы документов", "journal", ("Графы", "Формы", "Команды", "Макеты", "Права"), ("Модуль менеджера",)),
MetadataTypeSpec("ENUM", "Перечисление", "Перечисления", "enum", ("Значения", "Формы", "Команды", "Макеты", "Права"), ("Модуль менеджера",)),
MetadataTypeSpec("REPORT", "Отчет", "Отчеты", "report", REPORT_CHILDREN, OBJECT_MODULES),
MetadataTypeSpec("DATA_PROCESSOR", "Обработка", "Обработки", "processing", STRUCTURED_OBJECT_CHILDREN, OBJECT_MODULES),
MetadataTypeSpec("CHART_OF_CHARACTERISTIC_TYPES", "План видов характеристик", "Планы видов характеристик", "plan", STRUCTURED_OBJECT_CHILDREN + ("Предопределенные данные",), OBJECT_MODULES),
MetadataTypeSpec("CHART_OF_ACCOUNTS", "План счетов", "Планы счетов", "plan", ("Признаки учета", "Признаки учета субконто", "Реквизиты", "Табличные части", "Формы", "Команды", "Макеты", "Права", "Предопределенные данные"), OBJECT_MODULES),
MetadataTypeSpec("CHART_OF_CALCULATION_TYPES", "План видов расчета", "Планы видов расчета", "plan", STRUCTURED_OBJECT_CHILDREN + ("Вытесняющие виды расчета", "Ведущие виды расчета", "Базовые виды расчета"), OBJECT_MODULES),
MetadataTypeSpec("INFORMATION_REGISTER", "Регистр сведений", "Регистры сведений", "register", REGISTER_CHILDREN, RECORD_SET_MODULES),
MetadataTypeSpec("ACCUMULATION_REGISTER", "Регистр накопления", "Регистры накопления", "register", REGISTER_CHILDREN, RECORD_SET_MODULES),
MetadataTypeSpec("ACCOUNTING_REGISTER", "Регистр бухгалтерии", "Регистры бухгалтерии", "register", REGISTER_CHILDREN + ("Признаки учета", "Признаки учета субконто",), RECORD_SET_MODULES),
MetadataTypeSpec("CALCULATION_REGISTER", "Регистр расчета", "Регистры расчета", "register", REGISTER_CHILDREN + ("Перерасчеты",), RECORD_SET_MODULES),
MetadataTypeSpec("BUSINESS_PROCESS", "Бизнес-процесс", "Бизнес-процессы", "business-process", STRUCTURED_OBJECT_CHILDREN + ("Карта маршрута", "Точки маршрута"), OBJECT_MODULES),
MetadataTypeSpec("TASK", "Задача 1С", "Задачи", "task", STRUCTURED_OBJECT_CHILDREN + ("Адресация",), OBJECT_MODULES),
MetadataTypeSpec("EXTERNAL_DATA_SOURCE", "Внешний источник данных", "Внешние источники данных", "external-source", ("Таблицы", "Кубы", "Функции", "Формы", "Команды", "Макеты")),
MetadataTypeSpec("EXCHANGE_PLAN", "План обмена", "Планы обмена", "exchange-plan", STRUCTURED_OBJECT_CHILDREN + ("Состав",), OBJECT_MODULES),
MetadataTypeSpec("EVENT_SUBSCRIPTION", "Подписка на событие", "Подписки на события", "event", ("События",), HANDLER_METHOD),
MetadataTypeSpec("EXTENSION", "Расширение конфигурации", "Расширения конфигурации", "extension", ("Объекты расширения", "Заимствованные объекты", "Добавленные реквизиты", "Формы", "Команды", "Проверки совместимости")),
MetadataTypeSpec("SCHEDULED_JOB", "Регламентное задание", "Регламентные задания", "scheduled-job", ("Расписание", "Параметры"), ("Метод",)),
MetadataTypeSpec("SESSION_PARAMETER", "Параметр сеанса", "Параметры сеанса", "parameter"),
MetadataTypeSpec("COMMON_ATTRIBUTE", "Общий реквизит", "Общие реквизиты", "attribute"),
MetadataTypeSpec("FILTER_CRITERION", "Критерий отбора", "Критерии отбора", "filter"),
MetadataTypeSpec("FUNCTIONAL_OPTION", "Функциональная опция", "Функциональные опции", "option"),
MetadataTypeSpec("FUNCTIONAL_OPTION_PARAMETER", "Параметр функциональной опции", "Параметры функциональных опций", "parameter"),
MetadataTypeSpec("DEFINED_TYPE", "Определяемый тип", "Определяемые типы", "type"),
MetadataTypeSpec("SETTINGS_STORAGE", "Хранилище настроек", "Хранилища настроек", "storage"),
MetadataTypeSpec("COMMON_COMMAND", "Общая команда", "Общие команды", "command"),
MetadataTypeSpec("COMMAND_GROUP", "Группа команд", "Группы команд", "command-group"),
MetadataTypeSpec("COMMON_FORM", "Общая форма", "Общие формы", "form", ("Реквизиты", "Элементы", "Команды", "События", "Модуль формы")),
MetadataTypeSpec("COMMON_LAYOUT", "Общий макет", "Общие макеты", "layout"),
MetadataTypeSpec("COMMON_PICTURE", "Общая картинка", "Общие картинки", "picture"),
MetadataTypeSpec("XDTO_PACKAGE", "XDTO-пакет", "XDTO-пакеты", "xdto"),
MetadataTypeSpec("WEB_SERVICE", "Web-сервис", "Web-сервисы", "service", ("Операции", "Параметры", "Модуль")),
MetadataTypeSpec("HTTP_SERVICE", "HTTP-сервис", "HTTP-сервисы", "service", ("Шаблоны URL", "Методы", "Модуль")),
MetadataTypeSpec("WS_REFERENCE", "WS-ссылка", "WS-ссылки", "service", ("Операции", "Параметры")),
MetadataTypeSpec("WEBSOCKET_CLIENT", "WebSocket-клиент", "WebSocket-клиенты", "service", ("Модуль",)),
MetadataTypeSpec("INTEGRATION_SERVICE", "Сервис интеграции", "Сервисы интеграции", "service", ("Каналы", "Сообщения", "Модуль")),
MetadataTypeSpec("BOT", "Бот", "Боты", "service", ("Команды", "Модуль")),
MetadataTypeSpec("INTERFACE", "Интерфейс", "Интерфейсы", "interface"),
MetadataTypeSpec("FULL_TEXT_SEARCH_DICTIONARY", "Словарь полнотекстового поиска", "Словари полнотекстового поиска", "search"),
MetadataTypeSpec("PALETTE_COLOR", "Цвет палитры", "Цвета палитры", "style"),
MetadataTypeSpec("STYLE_ITEM", "Элемент стиля", "Элементы стиля", "style"),
MetadataTypeSpec("STYLE", "Стиль", "Стили", "style"),
MetadataTypeSpec("LANGUAGE", "Язык", "Языки", "language"),
)
METADATA_TYPE_DESCRIPTIONS = {
"COMMON": "Служебная ветка дерева конфигурации, объединяющая общие объекты метаданных.",
"COMMON_MODULE": "Общий модуль содержит процедуры и функции, доступные из разных областей выполнения конфигурации.",
"CONSTANT": "Константа хранит единичное значение конфигурации и может иметь формы, команды, права и модуль менеджера.",
"CATALOG": "Справочник описывает прикладной список объектов с реквизитами, табличными частями, формами, командами, макетами, правами и предопределенными данными.",
"DOCUMENT": "Документ фиксирует событие учета, имеет реквизиты, табличные части, формы, команды, макеты, права и движения по регистрам.",
"DOCUMENT_JOURNAL": "Журнал документов объединяет документы для совместного просмотра и может содержать графы, формы, команды, макеты и права.",
"ENUM": "Перечисление задает фиксированный набор значений, используемых в типах реквизитов и логике.",
"REPORT": "Отчет описывает формирование аналитических данных: реквизиты, табличные части, формы, команды, макеты и табличные документы, СКД, варианты, настройки, хранилища, справку, права и модули.",
"DATA_PROCESSOR": "Обработка реализует прикладный сценарий или сервисную операцию с формами, командами, макетами и модулями.",
"CHART_OF_CHARACTERISTIC_TYPES": "План видов характеристик описывает расширяемый набор характеристик объектов учета.",
"CHART_OF_ACCOUNTS": "План счетов описывает счета бухгалтерского учета, признаки учета, реквизиты, табличные части и предопределенные данные.",
"CHART_OF_CALCULATION_TYPES": "План видов расчета описывает виды расчетов, их вытеснение, ведущие и базовые виды.",
"INFORMATION_REGISTER": "Регистр сведений хранит независимые или подчиненные записи с измерениями, ресурсами и реквизитами.",
"ACCUMULATION_REGISTER": "Регистр накопления хранит движения ресурсов по измерениям для остатков и оборотов.",
"ACCOUNTING_REGISTER": "Регистр бухгалтерии хранит бухгалтерские движения по счетам и субконто.",
"CALCULATION_REGISTER": "Регистр расчета хранит записи расчетов, перерасчеты, измерения, ресурсы и реквизиты.",
"BUSINESS_PROCESS": "Бизнес-процесс описывает маршрут выполнения, точки маршрута, реквизиты, формы, команды и права.",
"TASK": "Задача описывает поручение пользователю или роли, включая адресацию, формы, команды и права.",
"EXTERNAL_DATA_SOURCE": "Внешний источник данных описывает подключение к внешним таблицам, кубам и функциям.",
"EXCHANGE_PLAN": "План обмена описывает узлы и состав данных для распределенного обмена.",
"EVENT_SUBSCRIPTION": "Подписка на событие связывает событие платформы или объекта с обработчиком.",
"EXTENSION": "Расширение конфигурации содержит добавленные и заимствованные объекты, а также проверки совместимости.",
"SCHEDULED_JOB": "Регламентное задание описывает метод, параметры и расписание фонового выполнения.",
"SESSION_PARAMETER": "Параметр сеанса задает значение, доступное в течение пользовательского сеанса.",
"COMMON_ATTRIBUTE": "Общий реквизит добавляет реквизит сразу к выбранному набору объектов конфигурации.",
"FILTER_CRITERION": "Критерий отбора задает состав реквизитов для универсального отбора ссылочных данных.",
"FUNCTIONAL_OPTION": "Функциональная опция управляет доступностью функциональности конфигурации.",
"FUNCTIONAL_OPTION_PARAMETER": "Параметр функциональной опции задает измерение, от которого зависит значение опции.",
"DEFINED_TYPE": "Определяемый тип задает именованный набор типов для повторного использования.",
"SETTINGS_STORAGE": "Хранилище настроек описывает место хранения пользовательских или системных настроек.",
"COMMON_COMMAND": "Общая команда описывает команду, доступную в нескольких формах или разделах интерфейса.",
"COMMAND_GROUP": "Группа команд объединяет команды для отображения в интерфейсе.",
"COMMON_FORM": "Общая форма описывает переиспользуемую форму с реквизитами, элементами, командами, событиями и модулем.",
"COMMON_LAYOUT": "Общий макет хранит общий шаблон, печатную форму, текстовый или двоичный ресурс.",
"COMMON_PICTURE": "Общая картинка хранит изображение, используемое в интерфейсе и макетах.",
"XDTO_PACKAGE": "XDTO-пакет описывает XML-типы и пространства имен для обмена данными.",
"WEB_SERVICE": "Web-сервис публикует SOAP-операции, параметры и обработчики в модуле.",
"HTTP_SERVICE": "HTTP-сервис публикует REST-подобные URL-шаблоны и HTTP-методы с обработчиками в модуле.",
"WS_REFERENCE": "WS-ссылка описывает внешний SOAP-сервис и его операции.",
"WEBSOCKET_CLIENT": "WebSocket-клиент описывает клиентское подключение и модуль обработки WebSocket-событий.",
"INTEGRATION_SERVICE": "Сервис интеграции описывает каналы, сообщения и код обработки интеграционного взаимодействия.",
"BOT": "Бот описывает команды и обработчики взаимодействия через поддерживаемые платформой каналы.",
"INTERFACE": "Интерфейс описывает устаревшую структуру командного интерфейса для совместимости.",
"FULL_TEXT_SEARCH_DICTIONARY": "Словарь полнотекстового поиска задает словарные данные для полнотекстового поиска.",
"PALETTE_COLOR": "Цвет палитры описывает общий цвет, используемый стилями и интерфейсом.",
"STYLE_ITEM": "Элемент стиля описывает отдельный параметр оформления.",
"STYLE": "Стиль объединяет элементы оформления прикладного интерфейса.",
"LANGUAGE": "Язык описывает язык интерфейса и локализованных представлений конфигурации.",
}
METADATA_TYPE_DOCUMENTATION_URLS = {
code: f"https://edt.1c.ru/dev/edt/2024.2/apidocs/com/_1c/g5/v8/dt/metadata/mdclass/{code.title().replace('_', '')}.html"
for code in METADATA_TYPE_DESCRIPTIONS
}
METADATA_TYPE_DOCUMENTATION_URLS.update(
{
"HTTP_SERVICE": "https://edt.1c.ru/dev/edt/2024.2/apidocs/com/_1c/g5/v8/dt/metadata/mdclass/HTTPService.html",
"WEB_SERVICE": "https://edt.1c.ru/dev/edt/2024.2/apidocs/com/_1c/g5/v8/dt/metadata/mdclass/WebService.html",
"INTEGRATION_SERVICE": "https://edt.1c.ru/dev/edt/2024.2/apidocs/com/_1c/g5/v8/dt/metadata/mdclass/IntegrationService.html",
"COMMON_LAYOUT": "https://edt.1c.ru/dev/edt/2024.2/apidocs/com/_1c/g5/v8/dt/metadata/mdclass/CommonTemplate.html",
"REPORT": "https://edt.1c.ru/dev/edt/2024.2/apidocs/com/_1c/g5/v8/dt/metadata/mdclass/Report.html",
}
)
METADATA_TYPE_PROPERTIES: dict[str, tuple[str, ...]] = {
"COMMON": ("Состав общих объектов",),
"COMMON_MODULE": STANDARD_PROPERTIES + ("Клиент", "Сервер", "Внешнее соединение", "Глобальный", "Вызов сервера", "Повторное использование возвращаемых значений"),
"CONSTANT": STANDARD_PROPERTIES + ("Тип значения", "Основная форма", "Форма выбора"),
"CATALOG": REFERENCE_OBJECT_PROPERTIES,
"DOCUMENT": DOCUMENT_PROPERTIES,
"DOCUMENT_JOURNAL": STANDARD_PROPERTIES + ("Регистрируемые документы", "Графы", "Основная форма", "Форма списка"),
"ENUM": STANDARD_PROPERTIES + ("Значения", "Основная форма", "Форма выбора"),
"REPORT": DATA_OBJECT_PROPERTIES + ("Основная схема компоновки данных", "Варианты", "Хранилище вариантов", "Хранилище настроек"),
"DATA_PROCESSOR": DATA_OBJECT_PROPERTIES + ("Основная форма", "Основная команда"),
"CHART_OF_CHARACTERISTIC_TYPES": REFERENCE_OBJECT_PROPERTIES + ("Тип значения характеристик",),
"CHART_OF_ACCOUNTS": REFERENCE_OBJECT_PROPERTIES + ("Длина кода счета", "Признаки учета", "Субконто"),
"CHART_OF_CALCULATION_TYPES": REFERENCE_OBJECT_PROPERTIES + ("Использует период действия", "Зависимость от базы", "Вытеснение"),
"INFORMATION_REGISTER": REGISTER_PROPERTIES + ("Периодический", "Подчинение регистратору", "Измерения", "Ресурсы"),
"ACCUMULATION_REGISTER": REGISTER_PROPERTIES + ("Вид регистра", "Измерения", "Ресурсы", "Использование итогов"),
"ACCOUNTING_REGISTER": REGISTER_PROPERTIES + ("План счетов", "Корреспонденция", "Субконто", "Разделение итогов"),
"CALCULATION_REGISTER": REGISTER_PROPERTIES + ("План видов расчета", "График", "Перерасчеты", "Базовый период"),
"BUSINESS_PROCESS": DATA_OBJECT_PROPERTIES + ("Карта маршрута", "Точки маршрута", "Задачи"),
"TASK": DATA_OBJECT_PROPERTIES + ("Адресация", "Исполнитель", "Бизнес-процесс"),
"EXTERNAL_DATA_SOURCE": STANDARD_PROPERTIES + ("Соединение", "Таблицы", "Кубы", "Функции"),
"EXCHANGE_PLAN": DATA_OBJECT_PROPERTIES + ("Состав обмена", "Распределенная ИБ", "Авторегистрация изменений"),
"EVENT_SUBSCRIPTION": STANDARD_PROPERTIES + ("Источник", "Событие", "Обработчик", "Перед/после события"),
"EXTENSION": ("Имя", "Назначение", "Версия", "Режим совместимости", "Заимствованные объекты", "Проверки совместимости"),
"SCHEDULED_JOB": STANDARD_PROPERTIES + ("Метод", "Расписание", "Использование", "Параметры", "Предопределенное"),
"SESSION_PARAMETER": STANDARD_PROPERTIES + ("Тип значения",),
"COMMON_ATTRIBUTE": STANDARD_PROPERTIES + ("Тип значения", "Состав", "Разделение данных", "Автоиспользование"),
"FILTER_CRITERION": STANDARD_PROPERTIES + ("Тип значения", "Состав реквизитов"),
"FUNCTIONAL_OPTION": STANDARD_PROPERTIES + ("Тип значения", "Хранение", "Состав объектов"),
"FUNCTIONAL_OPTION_PARAMETER": STANDARD_PROPERTIES + ("Тип значения", "Состав"),
"DEFINED_TYPE": STANDARD_PROPERTIES + ("Типы",),
"SETTINGS_STORAGE": STANDARD_PROPERTIES + ("Тип хранилища", "Модуль менеджера"),
"COMMON_COMMAND": STANDARD_PROPERTIES + ("Тип параметра команды", "Группа", "Представление", "Модуль команды"),
"COMMAND_GROUP": STANDARD_PROPERTIES + ("Категория", "Представление"),
"COMMON_FORM": STANDARD_PROPERTIES + ("Реквизиты", "Команды", "Элементы", "События", "Модуль формы"),
"COMMON_LAYOUT": STANDARD_PROPERTIES + ("Тип макета", "Файл/данные", "Назначение"),
"COMMON_PICTURE": STANDARD_PROPERTIES + ("Картинка", "Варианты", "Масштабирование"),
"XDTO_PACKAGE": STANDARD_PROPERTIES + ("URI пространства имен", "Типы", "Схемы"),
"WEB_SERVICE": STANDARD_PROPERTIES + ("URI пространства имен", "Операции", "Параметры", "Модуль"),
"HTTP_SERVICE": STANDARD_PROPERTIES + ("Корневой URL", "Шаблоны URL", "HTTP-методы", "Модуль"),
"WS_REFERENCE": STANDARD_PROPERTIES + ("WSDL", "Операции", "Параметры"),
"WEBSOCKET_CLIENT": STANDARD_PROPERTIES + ("URL", "Модуль", "События подключения"),
"INTEGRATION_SERVICE": STANDARD_PROPERTIES + ("Каналы", "Сообщения", "Форматы", "Модуль"),
"BOT": STANDARD_PROPERTIES + ("Команды", "Каналы", "Модуль"),
"INTERFACE": STANDARD_PROPERTIES + ("Командный интерфейс",),
"FULL_TEXT_SEARCH_DICTIONARY": STANDARD_PROPERTIES + ("Состав словаря",),
"PALETTE_COLOR": STANDARD_PROPERTIES + ("Цвет",),
"STYLE_ITEM": STANDARD_PROPERTIES + ("Значение стиля",),
"STYLE": STANDARD_PROPERTIES + ("Элементы стиля",),
"LANGUAGE": STANDARD_PROPERTIES + ("Код языка", "Локализованные строки"),
}
METADATA_TYPE_CONTEXT_ACTIONS: dict[str, tuple[str, ...]] = {
code: ("Открыть", "Показать свойства", "Показать связи", "Найти ссылки")
for code in METADATA_TYPE_DESCRIPTIONS
}
for code in {
"CATALOG",
"DOCUMENT",
"REPORT",
"DATA_PROCESSOR",
"CHART_OF_CHARACTERISTIC_TYPES",
"CHART_OF_ACCOUNTS",
"CHART_OF_CALCULATION_TYPES",
"BUSINESS_PROCESS",
"TASK",
"EXCHANGE_PLAN",
}:
METADATA_TYPE_CONTEXT_ACTIONS[code] = OBJECT_CONTEXT_ACTIONS
for code in {"INFORMATION_REGISTER", "ACCUMULATION_REGISTER", "ACCOUNTING_REGISTER", "CALCULATION_REGISTER"}:
METADATA_TYPE_CONTEXT_ACTIONS[code] = RECORD_SET_CONTEXT_ACTIONS
METADATA_TYPE_CONTEXT_ACTIONS.update(
{
"COMMON_MODULE": MODULE_CONTEXT_ACTIONS,
"COMMON_FORM": ("Открыть форму", "Открыть модуль формы", "Показать команды", "Показать реквизиты", "Найти ссылки"),
"COMMON_COMMAND": ("Открыть команду", "Открыть модуль команды", "Показать использование", "Найти ссылки"),
"HTTP_SERVICE": ("Открыть сервис", "Открыть модуль", "Показать URL-шаблоны", "Показать методы", "Показать интеграции"),
"WEB_SERVICE": ("Открыть сервис", "Открыть модуль", "Показать операции", "Показать параметры", "Показать интеграции"),
"SCHEDULED_JOB": ("Открыть задание", "Открыть метод", "Показать расписание", "Показать влияние"),
"EVENT_SUBSCRIPTION": ("Открыть подписку", "Открыть обработчик", "Показать источник события", "Показать влияние"),
"ROLE": ("Открыть роль", "Показать права", "Показать объекты доступа"),
}
)
METADATA_CHILD_OBJECT_SPECS: tuple[MetadataChildObjectSpec, ...] = (
MetadataChildObjectSpec("ATTRIBUTE", "Реквизит", ("Реквизиты",), "Хранит дополнительное поле объекта метаданных."),
MetadataChildObjectSpec("DIMENSION", "Измерение", ("Измерения",), "Задает аналитический разрез регистра."),
MetadataChildObjectSpec("RESOURCE", "Ресурс", ("Ресурсы",), "Задает накапливаемое или хранимое значение регистра."),
MetadataChildObjectSpec("TABULAR_SECTION", "Табличная часть", ("Табличные части",), "Описывает коллекцию строк внутри объекта."),
MetadataChildObjectSpec("FORM", "Форма", ("Формы", "Общие формы"), "Описывает пользовательскую форму объекта или общую форму."),
MetadataChildObjectSpec("COMMAND", "Команда", ("Команды", "Общие команды"), "Описывает действие интерфейса или объекта."),
MetadataChildObjectSpec("LAYOUT", "Макет", ("Макеты", "Общие макеты"), "Описывает печатный, текстовый или двоичный шаблон."),
MetadataChildObjectSpec("TABULAR_DOCUMENT", "Табличный документ", ("Табличные документы",), "Описывает табличный документ отчета или печатную форму."),
MetadataChildObjectSpec("DATA_COMPOSITION_SCHEMA", "Схема компоновки данных", ("СКД",), "Описывает основную или дополнительную схему компоновки данных отчета."),
MetadataChildObjectSpec("REPORT_VARIANT", "Вариант отчета", ("Варианты отчета",), "Описывает вариант настроек и представления отчета."),
MetadataChildObjectSpec("REPORT_SETTING", "Настройка отчета", ("Настройки",), "Описывает настройки отчета, включая формы и параметры компоновки."),
MetadataChildObjectSpec("REPORT_STORAGE", "Хранилище отчета", ("Хранилище вариантов", "Хранилище настроек"), "Описывает хранилище вариантов или настроек отчета."),
MetadataChildObjectSpec("HELP", "Справка", ("Справка",), "Описывает справочную информацию объекта метаданных."),
MetadataChildObjectSpec("MODULE", "Модуль", ("Модуль", "Модуль объекта", "Модуль менеджера", "Модуль формы", "Модуль набора записей", "Метод", "Обработчик"), "Содержит BSL-код обработчиков и прикладной логики."),
MetadataChildObjectSpec("RIGHT", "Право", ("Права",), "Описывает доступ роли к объекту или действию."),
MetadataChildObjectSpec("EVENT", "Событие", ("События",), "Описывает событие формы, объекта или подписки."),
MetadataChildObjectSpec("MOVEMENT", "Движение", ("Движения",), "Описывает движение документа по регистру."),
MetadataChildObjectSpec("ENUM_VALUE", "Значение перечисления", ("Значения",), "Описывает элемент фиксированного перечисления."),
MetadataChildObjectSpec("PREDEFINED", "Предопределенные данные", ("Предопределенные данные",), "Описывает предопределенный элемент справочника, плана или другого объекта."),
MetadataChildObjectSpec("URL_TEMPLATE", "Шаблон URL", ("Шаблоны URL",), "Описывает URL-шаблон HTTP-сервиса."),
MetadataChildObjectSpec("METHOD", "Метод", ("Методы", "Метод", "Функции"), "Описывает HTTP-метод, функцию внешнего источника или исполняемый метод."),
MetadataChildObjectSpec("OPERATION", "Операция", ("Операции",), "Описывает операцию Web-сервиса или WS-ссылки."),
MetadataChildObjectSpec("PARAMETER", "Параметр", ("Параметры",), "Описывает параметр метода, операции или регламентного задания."),
MetadataChildObjectSpec("CHANNEL", "Канал", ("Каналы",), "Описывает канал сервиса интеграции."),
MetadataChildObjectSpec("MESSAGE", "Сообщение", ("Сообщения",), "Описывает сообщение сервиса интеграции."),
MetadataChildObjectSpec("TABLE", "Таблица", ("Таблицы",), "Описывает таблицу внешнего источника данных."),
MetadataChildObjectSpec("CUBE", "Куб", ("Кубы",), "Описывает куб внешнего источника данных."),
MetadataChildObjectSpec("FIELD", "Поле", ("Графы", "Элементы", "Поля"), "Описывает графу журнала, поле таблицы или элемент формы."),
)
METADATA_TYPE_BY_CODE = {spec.code: spec for spec in METADATA_TYPE_SPECS}
METADATA_TYPE_BY_BRANCH = {spec.tree_branch: spec for spec in METADATA_TYPE_SPECS}
METADATA_CHILD_OBJECT_BY_CODE = {spec.code: spec for spec in METADATA_CHILD_OBJECT_SPECS}
__all__ = [
"COMMON_BRANCH_CHILDREN",
"METADATA_CHILD_OBJECT_BY_CODE",
"METADATA_CHILD_OBJECT_SPECS",
"METADATA_TYPE_BY_BRANCH",
"METADATA_TYPE_BY_CODE",
"METADATA_TYPE_DESCRIPTIONS",
"METADATA_TYPE_DOCUMENTATION_URLS",
"METADATA_TYPE_CONTEXT_ACTIONS",
"METADATA_TYPE_PROPERTIES",
"METADATA_TYPE_SPECS",
"MetadataChildObjectSpec",
"MetadataTypeSpec",
]
@@ -0,0 +1,658 @@
from pathlib import Path
from one_c_normalizer import (
COMMON_BRANCH_CHILDREN,
METADATA_TYPE_BY_BRANCH,
METADATA_TYPE_BY_CODE,
build_normalized_project,
normalize_bsl_source,
normalize_one_c_project,
normalize_source_path,
parse_one_c_xml_file,
)
def test_normalize_bsl_source_removes_bom_and_normalizes_newlines():
assert normalize_bsl_source("\ufeffПроцедура X()\r\n A = 1; \r\n\r\n") == (
"Процедура X()\n A = 1;\n"
)
def test_normalize_source_path_uses_forward_slashes():
assert normalize_source_path("src\\module.bsl") == "src/module.bsl"
def test_parse_one_c_xml_file_extracts_ui_objects(tmp_path: Path):
xml = tmp_path / "form.xml"
xml.write_text(
"""
<Form name="ФормаДокумента" qualifiedName="Документ.Заказ.ФормаДокумента">
<Command name="Провести" />
<Attribute name="Контрагент" />
</Form>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(xml)
assert [item.object_kind for item in objects] == ["FORM", "COMMAND", "ATTRIBUTE"]
assert objects[0].qualified_name == "Документ.Заказ.ФормаДокумента"
def test_parse_one_c_xml_file_extracts_metadata_objects(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Catalog name="Номенклатура" qualifiedName="Справочник.Номенклатура">
<Attribute name="Артикул" qualifiedName="Справочник.Номенклатура.Артикул" />
</Catalog>
<Document>
<Name>ЗаказПокупателя</Name>
<QualifiedName>Документ.ЗаказПокупателя</QualifiedName>
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары" />
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента" />
</Document>
<MetadataObject type="AccumulationRegister">
<Name>ОстаткиТоваров</Name>
<QualifiedName>РегистрНакопления.ОстаткиТоваров</QualifiedName>
</MetadataObject>
</Configuration>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(xml)
assert [item.object_kind for item in objects] == [
"CATALOG",
"ATTRIBUTE",
"DOCUMENT",
"TABULAR_SECTION",
"FORM",
"ACCUMULATION_REGISTER",
]
assert objects[2].qualified_name == "Документ.ЗаказПокупателя"
assert objects[5].name == "ОстаткиТоваров"
def test_normalize_one_c_project_preserves_configuration_root_metadata(tmp_path: Path):
xml = tmp_path / "configuration.xml"
xml.write_text(
"""
<Configuration name="УправлениеТорговлей" synonym="Управление торговлей" platformVersion="8.3.24" compatibilityMode="8.3.20">
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
</Configuration>
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="configuration-root")
assert normalized.configuration.name == "УправлениеТорговлей"
assert normalized.configuration.metadata["synonym"] == "Управление торговлей"
assert normalized.configuration.metadata["platformVersion"] == "8.3.24"
assert normalized.configuration.metadata["compatibilityMode"] == "8.3.20"
assert normalized.configuration.metadata["source_path"].endswith("configuration.xml")
assert [group.name for group in normalized.configuration.groups] == ["Справочники"]
def test_parse_edt_mdo_derives_qualified_names(tmp_path: Path):
mdo = tmp_path / "Товары.mdo"
mdo.write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Товары</name>
<attributes>
<name>Артикул</name>
</attributes>
<tabularSections>
<name>Цены</name>
<attributes>
<name>ВидЦены</name>
</attributes>
</tabularSections>
<forms>
<name>ФормаЭлемента</name>
</forms>
</mdclass:Catalog>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(mdo)
by_name = {item.name: item for item in objects}
assert by_name["Товары"].qualified_name == "Справочник.Товары"
assert by_name["Артикул"].qualified_name == "Справочник.Товары.Артикул"
assert by_name["Цены"].qualified_name == "Справочник.Товары.Цены"
assert by_name["ВидЦены"].qualified_name == "Справочник.Товары.Цены.ВидЦены"
assert by_name["ФормаЭлемента"].qualified_name == "Справочник.Товары.ФормаЭлемента"
def test_normalize_http_service_keeps_url_templates_and_methods(tmp_path: Path):
mdo = tmp_path / "ПубличныйAPI.mdo"
mdo.write_text(
"""
<mdclass:HTTPService xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>ПубличныйAPI</name>
<rootURL>api</rootURL>
<urlTemplates>
<name>Orders</name>
<template>/orders/{id}</template>
<methods>
<httpMethod>GET</httpMethod>
<handler>ПолучитьЗаказ</handler>
</methods>
</urlTemplates>
</mdclass:HTTPService>
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="http-service")
http_group = next(group for group in normalized.configuration.groups if group.name == "HTTP-сервисы")
service = http_group.objects[0]
assert service.qualified_name == "HTTPСервис.ПубличныйAPI"
assert service.metadata["rootURL"] == "api"
assert service.url_templates[0].name == "Orders"
assert service.url_templates[0].attributes["template"] == "/orders/{id}"
assert service.url_templates[0].children[0].kind == "METHOD"
assert service.url_templates[0].children[0].name == "ПолучитьЗаказ"
def test_normalize_edt_project_keeps_tabular_section_columns_nested(tmp_path: Path):
mdo = tmp_path / "ЗаказПокупателя.mdo"
mdo.write_text(
"""
<mdclass:Document xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>ЗаказПокупателя</name>
<tabularSections>
<name>Товары</name>
<attributes>
<name>Номенклатура</name>
</attributes>
<attributes>
<name>Количество</name>
</attributes>
</tabularSections>
</mdclass:Document>
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="edt-tabular")
documents = next(group for group in normalized.configuration.groups if group.name == "Документы")
document = documents.objects[0]
assert document.attributes == []
assert document.tabular_sections[0].qualified_name == "Документ.ЗаказПокупателя.Товары"
assert [child.name for child in document.tabular_sections[0].children] == ["Номенклатура", "Количество"]
assert [child.qualified_name for child in document.tabular_sections[0].children] == [
"Документ.ЗаказПокупателя.Товары.Номенклатура",
"Документ.ЗаказПокупателя.Товары.Количество",
]
def test_normalize_edt_project_preserves_source_path_and_common_object_descriptions(tmp_path: Path):
common_form = tmp_path / "ФормаПодбора.mdo"
common_form.write_text(
"""
<mdclass:CommonForm xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>ФормаПодбора</name>
<synonym>Форма подбора</synonym>
<comment>Используется в подборе товаров</comment>
</mdclass:CommonForm>
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="edt-common")
groups = {group.name: group for group in normalized.configuration.groups}
common_forms = groups["Общие формы"].objects
assert common_forms[0].qualified_name == "ОбщаяФорма.ФормаПодбора"
assert common_forms[0].source_path.endswith("ФормаПодбора.mdo")
assert common_forms[0].metadata["synonym"] == "Форма подбора"
assert common_forms[0].metadata["comment"] == "Используется в подборе товаров"
def test_normalize_edt_project_preserves_localized_descriptions(tmp_path: Path):
catalog = tmp_path / "Контрагенты.mdo"
catalog.write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Контрагенты</name>
<synonym>
<key>ru</key>
<value>Контрагенты</value>
</synonym>
<comment>
<key>ru</key>
<value>Описание справочника контрагентов</value>
</comment>
<attributes>
<name>ИНН</name>
<synonym>
<key>ru</key>
<value>ИНН</value>
</synonym>
<comment>
<key>ru</key>
<value>Идентификационный номер налогоплательщика</value>
</comment>
</attributes>
</mdclass:Catalog>
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="edt-localized")
catalogs = next(group for group in normalized.configuration.groups if group.name == "Справочники")
metadata_object = catalogs.objects[0]
attribute = metadata_object.attributes[0]
assert metadata_object.metadata["synonym"] == "Контрагенты"
assert metadata_object.metadata["synonym_localized"] == {"ru": "Контрагенты"}
assert metadata_object.metadata["comment"] == "Описание справочника контрагентов"
assert metadata_object.metadata["comment_localized"] == {"ru": "Описание справочника контрагентов"}
assert attribute.attributes["synonym"] == "ИНН"
assert attribute.attributes["comment"] == "Идентификационный номер налогоплательщика"
def test_normalize_edt_project_attaches_bsl_modules_to_metadata_objects(tmp_path: Path):
catalog_dir = tmp_path / "Catalogs" / "Контрагенты"
module_dir = catalog_dir / "Ext"
module_dir.mkdir(parents=True)
(catalog_dir / "Контрагенты.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Контрагенты</name>
</mdclass:Catalog>
""",
encoding="utf-8",
)
(module_dir / "ObjectModule.bsl").write_text(
"""
Процедура ПроверитьКонтрагента() Экспорт
КонецПроцедуры
""",
encoding="utf-8",
)
(module_dir / "ManagerModule.bsl").write_text(
"""
Процедура Создать() Экспорт
КонецПроцедуры
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="edt-modules")
catalogs = next(group for group in normalized.configuration.groups if group.name == "Справочники")
catalog = catalogs.objects[0]
assert [module.module_kind for module in catalog.modules] == ["MANAGER_MODULE", "OBJECT_MODULE"]
assert all(module.source_path.endswith(".bsl") for module in catalog.modules)
assert all(module.attributes["source_hash"] for module in catalog.modules)
def test_normalize_edt_project_attaches_form_modules_to_owner_with_form_name(tmp_path: Path):
catalog_dir = tmp_path / "Catalogs" / "Контрагенты"
form_dir = catalog_dir / "Forms" / "ФормаЭлемента" / "Ext"
form_dir.mkdir(parents=True)
(catalog_dir / "Контрагенты.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Контрагенты</name>
<forms>
<name>ФормаЭлемента</name>
</forms>
</mdclass:Catalog>
""",
encoding="utf-8",
)
(form_dir / "Module.bsl").write_text(
"""
Процедура ПриОткрытии()
КонецПроцедуры
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="edt-form-module")
catalog = next(group for group in normalized.configuration.groups if group.name == "Справочники").objects[0]
assert catalog.modules[0].module_kind == "FORM_MODULE"
assert catalog.modules[0].attributes["form_name"] == "ФормаЭлемента"
assert catalog.modules[0].qualified_name == "Справочник.Контрагенты.Форма.ФормаЭлемента.Модуль"
def test_normalize_edt_project_attaches_common_form_modules(tmp_path: Path):
common_form_dir = tmp_path / "CommonForms" / "ФормаПодбора"
module_dir = common_form_dir / "Ext"
module_dir.mkdir(parents=True)
(common_form_dir / "ФормаПодбора.mdo").write_text(
"""
<mdclass:CommonForm xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>ФормаПодбора</name>
</mdclass:CommonForm>
""",
encoding="utf-8",
)
(module_dir / "Module.bsl").write_text(
"""
Процедура ПриСозданииНаСервере() Экспорт
КонецПроцедуры
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="edt-common-form-module")
common_forms = next(group for group in normalized.configuration.groups if group.name == "Общие формы")
common_form = common_forms.objects[0]
assert common_form.qualified_name == "ОбщаяФорма.ФормаПодбора"
assert len(common_form.modules) == 1
assert common_form.modules[0].module_kind == "MODULE"
assert common_form.modules[0].source_path.endswith("Module.bsl")
def test_parse_register_dimensions_and_resources_preserves_attribute_roles(tmp_path: Path):
mdo = tmp_path / "ОстаткиТоваров.mdo"
mdo.write_text(
"""
<mdclass:AccumulationRegister xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>ОстаткиТоваров</name>
<dimensions>
<name>Номенклатура</name>
</dimensions>
<resources>
<name>Количество</name>
</resources>
<attributes>
<name>Комментарий</name>
</attributes>
</mdclass:AccumulationRegister>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(mdo)
by_name = {item.name: item for item in objects}
assert by_name["ОстаткиТоваров"].object_kind == "ACCUMULATION_REGISTER"
assert by_name["ОстаткиТоваров"].qualified_name == "РегистрНакопления.ОстаткиТоваров"
assert by_name["Номенклатура"].attributes["attribute_role"] == "DIMENSION"
assert by_name["Количество"].attributes["attribute_role"] == "RESOURCE"
assert by_name["Комментарий"].attributes["attribute_role"] == "REQUISITE"
def test_normalize_edt_project_preserves_specific_register_kinds(tmp_path: Path):
for file_name, tag, name in (
("Цены.mdo", "InformationRegister", "Цены"),
("ОстаткиТоваров.mdo", "AccumulationRegister", "ОстаткиТоваров"),
("Хозрасчетный.mdo", "AccountingRegister", "Хозрасчетный"),
("Начисления.mdo", "CalculationRegister", "Начисления"),
):
(tmp_path / file_name).write_text(
f"""
<mdclass:{tag} xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>{name}</name>
</mdclass:{tag}>
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="edt-register-kinds")
groups = {group.name: group for group in normalized.configuration.groups}
assert groups["Регистры сведений"].objects[0].object_kind == "INFORMATION_REGISTER"
assert groups["Регистры накопления"].objects[0].qualified_name == "РегистрНакопления.ОстаткиТоваров"
assert groups["Регистры бухгалтерии"].objects[0].object_kind == "ACCOUNTING_REGISTER"
assert groups["Регистры расчета"].objects[0].object_kind == "CALCULATION_REGISTER"
def test_parse_one_c_xml_file_extracts_role_rights(tmp_path: Path):
xml = tmp_path / "roles.xml"
xml.write_text(
"""
<Configuration>
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
<Right object="Документ.ЗаказПокупателя" read="true" write="true" post="true" />
</Role>
</Configuration>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(xml)
assert [item.object_kind for item in objects] == ["ROLE", "RIGHT"]
assert objects[1].name == "Документ.ЗаказПокупателя"
assert objects[1].attributes["role"] == "Роль.Менеджер"
assert objects[1].attributes["post"] == "true"
def test_normalized_project_attaches_role_rights(tmp_path: Path):
xml = tmp_path / "roles.xml"
xml.write_text(
"""
<Configuration>
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
<Right object="Документ.ЗаказПокупателя" read="true" write="true" />
</Role>
</Configuration>
""",
encoding="utf-8",
)
normalized = build_normalized_project(parse_one_c_xml_file(xml), project_id="roles", source_path=str(tmp_path))
roles_group = next(group for group in normalized.configuration.groups if group.name == "Роли")
role = roles_group.objects[0]
assert role.name == "Менеджер"
assert role.rights[0].target == "Документ.ЗаказПокупателя"
assert role.rights[0].permissions["write"] == "true"
def test_parse_one_c_xml_file_extracts_child_element_command_action_and_role_right(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document>
<Name>ЗаказПокупателя</Name>
<QualifiedName>Документ.ЗаказПокупателя</QualifiedName>
<Form>
<Name>ФормаДокумента</Name>
<Command>
<Name>Провести</Name>
<Action>ПровестиКоманда</Action>
</Command>
</Form>
</Document>
<Role>
<Name>Менеджер</Name>
<Right read="true">
<Object>Документ.ЗаказПокупателя</Object>
</Right>
</Role>
</Configuration>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(xml)
by_kind = {item.object_kind: item for item in objects}
command = next(item for item in objects if item.object_kind == "COMMAND")
right = next(item for item in objects if item.object_kind == "RIGHT")
assert by_kind["FORM"].qualified_name == "Документ.ЗаказПокупателя.ФормаДокумента"
assert command.attributes["Action"] == "ПровестиКоманда"
assert right.name == "Документ.ЗаказПокупателя"
assert right.attributes["role"] == "Роль.Менеджер"
def test_normalized_project_groups_extended_1c_objects(tmp_path: Path):
xml = tmp_path / "extended.xml"
xml.write_text(
"""
<Configuration>
<Subsystem name="Продажи" qualifiedName="Подсистема.Продажи" />
<HTTPService name="ПубличныйAPI" qualifiedName="HTTPСервис.ПубличныйAPI" />
<XDTOPackage name="ИнтеграцияCRM" qualifiedName="XDTO.ИнтеграцияCRM" />
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Layout name="ПечатнаяФорма" qualifiedName="Документ.ЗаказПокупателя.ПечатнаяФорма" />
<Movement name="Остатки" qualifiedName="Документ.ЗаказПокупателя.Движения.Остатки" />
</Document>
<Extension name="CRM" version="1.0" />
</Configuration>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(xml)
normalized = build_normalized_project(objects, project_id="extended", source_path=str(tmp_path))
groups = {group.name: group for group in normalized.configuration.groups}
document = next(item for item in groups["Документы"].objects if item.name == "ЗаказПокупателя")
assert "Подсистемы" in groups
assert "HTTP-сервисы" in groups
assert "XDTO-пакеты" in groups
assert document.layouts[0].name == "ПечатнаяФорма"
assert document.movements[0].name == "Остатки"
assert normalized.configuration.extensions[0].name == "CRM"
def test_normalize_one_c_project_skips_unreadable_or_invalid_metadata(tmp_path: Path):
(tmp_path / "valid.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
</Configuration>
""",
encoding="utf-8",
)
(tmp_path / "broken.xml").write_text("<Configuration><Catalog>", encoding="utf-8")
normalized = normalize_one_c_project(tmp_path, project_id="skip-invalid")
catalogs = next(group for group in normalized.configuration.groups if group.name == "Справочники")
assert catalogs.objects[0].qualified_name == "Справочник.Контрагенты"
def test_configuration_extension_has_own_metadata_tree_structure(tmp_path: Path):
xml = tmp_path / "extension.mdo"
xml.write_text(
"""
<mdclass:ConfigurationExtension xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass/extension">
<name>CRM</name>
<version>1.0</version>
<catalogs>
<name>КонтрагентыCRM</name>
<attributes>
<name>ВнешнийКод</name>
</attributes>
</catalogs>
<commonModules>
<name>CRMСервер</name>
</commonModules>
</mdclass:ConfigurationExtension>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(xml)
normalized = build_normalized_project(objects, project_id="extension-structure", source_path=str(tmp_path))
assert normalized.configuration.groups == []
extension = normalized.configuration.extensions[0]
assert extension.name == "CRM"
assert extension.version == "1.0"
extension_groups = {group.name: group for group in extension.groups}
assert extension_groups["Справочники"].objects[0].name == "КонтрагентыCRM"
assert extension_groups["Справочники"].objects[0].attributes[0].name == "ВнешнийКод"
assert extension_groups["Общие модули"].objects[0].name == "CRMСервер"
def test_report_metadata_parts_include_dcs_variants_settings_and_tabular_documents(tmp_path: Path):
xml = tmp_path / "report.mdo"
xml.write_text(
"""
<mdclass:Report xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>АнализПродаж</name>
<attributes>
<name>Период</name>
</attributes>
<tabularSections>
<name>Показатели</name>
</tabularSections>
<forms>
<name>ФормаОтчета</name>
</forms>
<templates>
<name>ПечатнаяФорма</name>
</templates>
<tabularDocuments>
<name>ТабличныйДокумент</name>
</tabularDocuments>
<mainDataCompositionSchema>
<name>ОсновнаяСхемаКомпоновкиДанных</name>
</mainDataCompositionSchema>
<reportVariants>
<name>Основной</name>
</reportVariants>
<settings>
<name>НастройкиПоУмолчанию</name>
</settings>
</mdclass:Report>
""",
encoding="utf-8",
)
objects = parse_one_c_xml_file(xml)
normalized = build_normalized_project(objects, project_id="report-parts", source_path=str(tmp_path))
reports = next(group for group in normalized.configuration.groups if group.name == "Отчеты")
report = reports.objects[0]
assert report.qualified_name == "Отчет.АнализПродаж"
assert report.attributes[0].name == "Период"
assert report.tabular_sections[0].name == "Показатели"
assert report.forms[0].name == "ФормаОтчета"
assert report.layouts[0].name == "ПечатнаяФорма"
assert report.tabular_documents[0].name == "ТабличныйДокумент"
assert report.data_composition_schemas[0].name == "ОсновнаяСхемаКомпоновкиДанных"
assert report.report_variants[0].name == "Основной"
assert report.report_settings[0].name == "НастройкиПоУмолчанию"
def test_metadata_catalog_describes_core_1c_tree_branches():
assert "Общие модули" in COMMON_BRANCH_CHILDREN
assert "HTTP-сервисы" in COMMON_BRANCH_CHILDREN
document = METADATA_TYPE_BY_CODE["DOCUMENT"]
assert document.tree_branch == "Документы"
assert "Реквизиты" in document.child_groups
assert "Табличные части" in document.child_groups
assert "Движения" in document.child_groups
assert document.module_kinds == ("Модуль объекта", "Модуль менеджера")
register = METADATA_TYPE_BY_CODE["ACCUMULATION_REGISTER"]
assert register.tree_branch == "Регистры накопления"
assert "Измерения" in register.child_groups
assert "Ресурсы" in register.child_groups
common_form = METADATA_TYPE_BY_CODE["COMMON_FORM"]
assert common_form.tree_branch == "Общие формы"
assert "Модуль формы" in common_form.child_groups
report = METADATA_TYPE_BY_CODE["REPORT"]
assert "СКД" in report.child_groups
assert "Табличные документы" in report.child_groups
assert "Варианты отчета" in report.child_groups
assert "Настройки" in report.child_groups
assert METADATA_TYPE_BY_BRANCH["XDTO-пакеты"].code == "XDTO_PACKAGE"
assert METADATA_TYPE_BY_BRANCH["Сервисы интеграции"].code == "INTEGRATION_SERVICE"
+3
View File
@@ -0,0 +1,3 @@
# sfera-operations-core
Operations primitives for jobs, observability, reports, marketplace inventory, and licensing.
+12
View File
@@ -0,0 +1,12 @@
[project]
name = "sfera-operations-core"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-integration-topology",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,308 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field, computed_field
from integration_topology import IntegrationKind, build_integration_topology
from sir import EdgeKind, NodeKind, SirSnapshot
class OperationJobStatus(str, Enum):
QUEUED = "QUEUED"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
CANCELED = "CANCELED"
class OperationJob(BaseModel):
job_id: str
kind: str
status: OperationJobStatus = OperationJobStatus.QUEUED
payload: dict = Field(default_factory=dict)
result: dict = Field(default_factory=dict)
error: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class MetricSample(BaseModel):
metric_id: str
name: str
value: float
unit: str | None = None
observed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: dict[str, str] = Field(default_factory=dict)
class AiUsageRecord(BaseModel):
usage_id: str
project_id: str
user_id: str
model: str
operation: str
prompt_tokens: int = 0
completion_tokens: int = 0
cost: float = 0.0
currency: str = "USD"
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
attributes: dict = Field(default_factory=dict)
@computed_field
@property
def total_tokens(self) -> int:
return self.prompt_tokens + self.completion_tokens
class AiUsageSummary(BaseModel):
project_id: str | None = None
user_id: str | None = None
model: str | None = None
operation: str | None = None
request_count: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
cost: float = 0.0
currency: str = "USD"
class ProjectReport(BaseModel):
project_id: str
snapshot_id: str
node_count: int
edge_count: int
procedure_count: int
query_count: int
write_count: int
role_count: int = 0
access_grant_count: int = 0
attribute_count: int = 0
object_attribute_count: int = 0
tabular_section_count: int = 0
tabular_section_column_count: int = 0
empty_tabular_section_count: int = 0
empty_tabular_sections: list[str] = Field(default_factory=list)
unsecured_object_count: int = 0
unsecured_objects: list[str] = Field(default_factory=list)
integration_count: int = 0
outbound_integration_count: int = 0
integration_kinds: dict[str, int] = Field(default_factory=dict)
diagnostic_count: int
generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class MarketplacePackage(BaseModel):
package_id: str
name: str
version: str
description: str = ""
enabled: bool = True
attributes: dict = Field(default_factory=dict)
class LicenseState(BaseModel):
license_id: str = "community"
plan: str = "community"
valid: bool = True
seats: int | None = None
expires_at: datetime | None = None
class InMemoryOperationsStore:
def __init__(self) -> None:
self.jobs: dict[str, OperationJob] = {}
self.metrics: list[MetricSample] = []
self.ai_usage: dict[str, AiUsageRecord] = {}
self.marketplace: dict[str, MarketplacePackage] = {}
self.license_state = LicenseState()
def enqueue(self, job: OperationJob) -> OperationJob:
self.jobs[job.job_id] = job
return job
def update_job(
self,
job_id: str,
status: OperationJobStatus,
*,
result: dict | None = None,
error: str | None = None,
) -> OperationJob:
job = self.jobs[job_id]
job.status = status
job.result = result or job.result
job.error = error
job.updated_at = datetime.now(timezone.utc)
return job
def list_jobs(self, status: OperationJobStatus | None = None) -> list[OperationJob]:
jobs = list(self.jobs.values())
if status is not None:
jobs = [job for job in jobs if job.status == status]
return sorted(jobs, key=lambda job: job.created_at, reverse=True)
def record_metric(self, metric: MetricSample) -> MetricSample:
self.metrics.append(metric)
return metric
def list_metrics(self, name: str | None = None) -> list[MetricSample]:
samples = self.metrics
if name is not None:
samples = [sample for sample in samples if sample.name == name]
return sorted(samples, key=lambda sample: sample.observed_at, reverse=True)
def record_ai_usage(self, usage: AiUsageRecord) -> AiUsageRecord:
self.ai_usage[usage.usage_id] = usage
return usage
def list_ai_usage(
self,
*,
project_id: str | None = None,
user_id: str | None = None,
model: str | None = None,
) -> list[AiUsageRecord]:
records = list(self.ai_usage.values())
if project_id is not None:
records = [record for record in records if record.project_id == project_id]
if user_id is not None:
records = [record for record in records if record.user_id == user_id]
if model is not None:
records = [record for record in records if record.model == model]
return sorted(records, key=lambda record: record.created_at, reverse=True)
def summarize_ai_usage(
self,
*,
project_id: str | None = None,
user_id: str | None = None,
model: str | None = None,
operation: str | None = None,
) -> AiUsageSummary:
records = self.list_ai_usage(project_id=project_id, user_id=user_id, model=model)
if operation is not None:
records = [record for record in records if record.operation == operation]
prompt_tokens = sum(record.prompt_tokens for record in records)
completion_tokens = sum(record.completion_tokens for record in records)
return AiUsageSummary(
project_id=project_id,
user_id=user_id,
model=model,
operation=operation,
request_count=len(records),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
cost=sum(record.cost for record in records),
)
def upsert_marketplace_package(self, package: MarketplacePackage) -> MarketplacePackage:
self.marketplace[package.package_id] = package
return package
def list_marketplace_packages(self, enabled_only: bool = False) -> list[MarketplacePackage]:
packages = list(self.marketplace.values())
if enabled_only:
packages = [package for package in packages if package.enabled]
return sorted(packages, key=lambda package: package.name)
def set_license(self, license_state: LicenseState) -> LicenseState:
self.license_state = license_state
return license_state
def build_project_report(snapshot: SirSnapshot) -> ProjectReport:
integration_topology = build_integration_topology(snapshot)
integration_kinds = {
kind.value: len(integration_topology.by_kind(kind))
for kind in IntegrationKind
if integration_topology.by_kind(kind)
}
access_target_kinds = {
NodeKind.CATALOG,
NodeKind.DOCUMENT,
NodeKind.REGISTER,
NodeKind.COMMON_MODULE,
NodeKind.EXCHANGE_PLAN,
NodeKind.SCHEDULED_JOB,
NodeKind.BUSINESS_PROCESS,
NodeKind.TASK,
}
secured_lineages = {
edge.target_lineage
for edge in snapshot.edges
if edge.kind == EdgeKind.GRANTS_ACCESS
}
unsecured_objects = sorted(
node.qualified_name
for node in snapshot.nodes
if node.kind in access_target_kinds and node.lineage_id not in secured_lineages
)
nodes = {node.lineage_id: node for node in snapshot.nodes}
object_attribute_edges = [
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.HAS_ATTRIBUTE
and (source := nodes.get(edge.source_lineage)) is not None
and source.kind in access_target_kinds
]
tabular_section_lineages = {
node.lineage_id
for node in snapshot.nodes
if node.kind == NodeKind.TABULAR_SECTION
}
tabular_section_column_edges = [
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.HAS_ATTRIBUTE and edge.source_lineage in tabular_section_lineages
]
tabular_sections_with_columns = {edge.source_lineage for edge in tabular_section_column_edges}
empty_tabular_sections = sorted(
nodes[lineage].qualified_name
for lineage in tabular_section_lineages
if lineage not in tabular_sections_with_columns
)
return ProjectReport(
project_id=snapshot.project_id,
snapshot_id=snapshot.snapshot_id,
node_count=len(snapshot.nodes),
edge_count=len(snapshot.edges),
procedure_count=sum(
1 for node in snapshot.nodes if node.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION}
),
query_count=sum(1 for node in snapshot.nodes if node.kind == NodeKind.QUERY),
write_count=sum(1 for edge in snapshot.edges if edge.kind == EdgeKind.WRITES),
role_count=sum(1 for node in snapshot.nodes if node.kind == NodeKind.ROLE),
access_grant_count=sum(1 for edge in snapshot.edges if edge.kind == EdgeKind.GRANTS_ACCESS),
attribute_count=sum(1 for node in snapshot.nodes if node.kind == NodeKind.ATTRIBUTE),
object_attribute_count=len(object_attribute_edges),
tabular_section_count=len(tabular_section_lineages),
tabular_section_column_count=len(tabular_section_column_edges),
empty_tabular_section_count=len(empty_tabular_sections),
empty_tabular_sections=empty_tabular_sections,
unsecured_object_count=len(unsecured_objects),
unsecured_objects=unsecured_objects,
integration_count=len(integration_topology.endpoints),
outbound_integration_count=sum(
1 for endpoint in integration_topology.endpoints if endpoint.direction == "OUTBOUND"
),
integration_kinds=integration_kinds,
diagnostic_count=len(snapshot.diagnostics),
)
__all__ = [
"AiUsageRecord",
"AiUsageSummary",
"InMemoryOperationsStore",
"LicenseState",
"MarketplacePackage",
"MetricSample",
"OperationJob",
"OperationJobStatus",
"ProjectReport",
"build_project_report",
]
@@ -0,0 +1,148 @@
from pathlib import Path
from operations_core import (
AiUsageRecord,
InMemoryOperationsStore,
MarketplacePackage,
OperationJob,
OperationJobStatus,
build_project_report,
)
from semantic_kernel import index_project
def test_operations_store_jobs_and_marketplace():
store = InMemoryOperationsStore()
job = store.enqueue(OperationJob(job_id="job.1", kind="INDEX_PROJECT"))
store.update_job(job.job_id, OperationJobStatus.SUCCEEDED, result={"ok": True})
store.upsert_marketplace_package(
MarketplacePackage(package_id="pack.1", name="BSP Knowledge", version="1.0.0")
)
assert store.list_jobs()[0].status == OperationJobStatus.SUCCEEDED
assert store.list_marketplace_packages()[0].name == "BSP Knowledge"
def test_operations_store_ai_usage_summary():
store = InMemoryOperationsStore()
store.record_ai_usage(
AiUsageRecord(
usage_id="usage.1",
project_id="demo",
user_id="user.1",
model="gpt-test",
operation="review",
prompt_tokens=100,
completion_tokens=40,
cost=0.01,
)
)
store.record_ai_usage(
AiUsageRecord(
usage_id="usage.2",
project_id="demo",
user_id="user.1",
model="gpt-test",
operation="impact",
prompt_tokens=50,
completion_tokens=10,
cost=0.005,
)
)
summary = store.summarize_ai_usage(project_id="demo", user_id="user.1", model="gpt-test")
assert summary.request_count == 2
assert summary.total_tokens == 200
assert summary.cost == 0.015
def test_build_project_report_counts_snapshot_parts(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="demo")
report = build_project_report(snapshot)
assert report.procedure_count == 1
assert report.write_count == 1
def test_build_project_report_counts_1c_role_access(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
<Catalog name="Номенклатура" qualifiedName="Справочник.Номенклатура" />
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
<Right object="Документ.ЗаказПокупателя" read="true" write="true" />
</Role>
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="security-report")
report = build_project_report(snapshot)
assert report.role_count == 1
assert report.access_grant_count == 1
assert report.unsecured_object_count == 1
assert report.unsecured_objects == ["Справочник.Номенклатура"]
def test_build_project_report_counts_1c_schema(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
<TabularSection name="Услуги" qualifiedName="Документ.ЗаказПокупателя.Услуги" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="schema-report")
report = build_project_report(snapshot)
assert report.attribute_count == 2
assert report.object_attribute_count == 1
assert report.tabular_section_count == 2
assert report.tabular_section_column_count == 1
assert report.empty_tabular_section_count == 1
assert report.empty_tabular_sections == ["Документ.ЗаказПокупателя.Услуги"]
def test_build_project_report_counts_integrations(tmp_path: Path):
module = tmp_path / "integration.bsl"
module.write_text(
"""
Процедура Отправить()
Адрес = "https://api.example.local/orders";
Объект = Новый COMОбъект("V83.Application");
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="integration-report")
report = build_project_report(snapshot)
assert report.integration_count == 2
assert report.outbound_integration_count == 2
assert report.integration_kinds["HTTP_SERVICE"] == 1
assert report.integration_kinds["COM_CONNECTOR"] == 1
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-pattern-mining"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,132 @@
from __future__ import annotations
from collections import defaultdict
from pydantic import BaseModel, Field
from sir import EdgeKind, NodeKind, SemanticNode, SirSnapshot
class SemanticPattern(BaseModel):
pattern_id: str
kind: str
title: str
participants: list[SemanticNode] = Field(default_factory=list)
targets: list[SemanticNode] = Field(default_factory=list)
support: int
attributes: dict = Field(default_factory=dict)
def mine_patterns(snapshot: SirSnapshot, *, min_support: int = 2) -> list[SemanticPattern]:
nodes = {node.lineage_id: node for node in snapshot.nodes}
patterns: list[SemanticPattern] = []
patterns.extend(_table_usage_patterns(snapshot, nodes, min_support=min_support))
patterns.extend(_routine_read_write_patterns(snapshot, nodes, min_support=min_support))
return sorted(patterns, key=lambda item: (item.kind, -item.support, item.title))
def _table_usage_patterns(
snapshot: SirSnapshot,
nodes: dict[str, SemanticNode],
*,
min_support: int,
) -> list[SemanticPattern]:
query_owner: dict[str, SemanticNode] = {}
for edge in snapshot.edges:
if edge.kind == EdgeKind.OWNS_QUERY and edge.source_lineage in nodes:
query_owner[edge.target_lineage] = nodes[edge.source_lineage]
readers_by_table: dict[str, dict[str, SemanticNode]] = defaultdict(dict)
writers_by_table: dict[str, dict[str, SemanticNode]] = defaultdict(dict)
for edge in snapshot.edges:
if edge.kind == EdgeKind.READS_TABLE:
table = nodes.get(edge.target_lineage)
owner = query_owner.get(edge.source_lineage)
if table is not None and owner is not None:
readers_by_table[table.lineage_id][owner.lineage_id] = owner
elif edge.kind == EdgeKind.WRITES:
table = nodes.get(edge.target_lineage)
writer = nodes.get(edge.source_lineage)
if table is not None and writer is not None:
writers_by_table[table.lineage_id][writer.lineage_id] = writer
patterns: list[SemanticPattern] = []
for table_lineage, readers in readers_by_table.items():
table = nodes[table_lineage]
if len(readers) >= min_support:
patterns.append(
SemanticPattern(
pattern_id=f"pattern.repeated_read.{table.lineage_id}",
kind="REPEATED_TABLE_READ",
title=f"Repeated reads of {table.qualified_name}",
participants=sorted(readers.values(), key=lambda node: node.qualified_name),
targets=[table],
support=len(readers),
)
)
for table_lineage, writers in writers_by_table.items():
table = nodes[table_lineage]
if len(writers) >= min_support:
patterns.append(
SemanticPattern(
pattern_id=f"pattern.repeated_write.{table.lineage_id}",
kind="REPEATED_TABLE_WRITE",
title=f"Repeated writes to {table.qualified_name}",
participants=sorted(writers.values(), key=lambda node: node.qualified_name),
targets=[table],
support=len(writers),
)
)
return patterns
def _routine_read_write_patterns(
snapshot: SirSnapshot,
nodes: dict[str, SemanticNode],
*,
min_support: int,
) -> list[SemanticPattern]:
query_owner: dict[str, str] = {}
for edge in snapshot.edges:
if edge.kind == EdgeKind.OWNS_QUERY:
query_owner[edge.target_lineage] = edge.source_lineage
reads_by_routine: dict[str, set[str]] = defaultdict(set)
writes_by_routine: dict[str, set[str]] = defaultdict(set)
for edge in snapshot.edges:
if edge.kind == EdgeKind.READS_TABLE and edge.source_lineage in query_owner:
reads_by_routine[query_owner[edge.source_lineage]].add(edge.target_lineage)
elif edge.kind == EdgeKind.WRITES:
writes_by_routine[edge.source_lineage].add(edge.target_lineage)
grouped: dict[tuple[tuple[str, ...], tuple[str, ...]], list[SemanticNode]] = defaultdict(list)
routine_kinds = {NodeKind.PROCEDURE, NodeKind.FUNCTION}
for routine_lineage in sorted(set(reads_by_routine) | set(writes_by_routine)):
routine = nodes.get(routine_lineage)
if routine is None or routine.kind not in routine_kinds:
continue
key = (tuple(sorted(reads_by_routine[routine_lineage])), tuple(sorted(writes_by_routine[routine_lineage])))
if key == ((), ()):
continue
grouped[key].append(routine)
patterns: list[SemanticPattern] = []
for index, ((reads, writes), routines) in enumerate(sorted(grouped.items()), start=1):
if len(routines) < min_support:
continue
targets = [nodes[lineage] for lineage in [*reads, *writes] if lineage in nodes]
patterns.append(
SemanticPattern(
pattern_id=f"pattern.routine_io.{index}",
kind="REPEATED_ROUTINE_IO",
title="Repeated routine read/write shape",
participants=sorted(routines, key=lambda node: node.qualified_name),
targets=sorted(targets, key=lambda node: node.qualified_name),
support=len(routines),
attributes={"read_count": len(reads), "write_count": len(writes)},
)
)
return patterns
__all__ = ["SemanticPattern", "mine_patterns"]
@@ -0,0 +1,59 @@
from pathlib import Path
from pattern_mining import mine_patterns
from semantic_kernel import index_project
def test_mine_patterns_finds_repeated_table_writes(tmp_path: Path):
(tmp_path / "module.bsl").write_text(
"""
Процедура ПровестиЗаказ()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ОтменитьЗаказ()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="patterns")
patterns = mine_patterns(snapshot)
repeated_writes = [pattern for pattern in patterns if pattern.kind == "REPEATED_TABLE_WRITE"]
assert repeated_writes
assert repeated_writes[0].support == 2
assert repeated_writes[0].targets[0].name == "ОстаткиТоваров"
def test_mine_patterns_finds_repeated_query_reads(tmp_path: Path):
(tmp_path / "module.bsl").write_text(
"""
Процедура ПроверитьОстатки()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Остатки.Номенклатура
ИЗ
РегистрНакопления.ОстаткиТоваров КАК Остатки";
КонецПроцедуры
Процедура РассчитатьОстатки()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Остатки.Номенклатура
ИЗ
РегистрНакопления.ОстаткиТоваров КАК Остатки";
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="patterns")
patterns = mine_patterns(snapshot)
repeated_reads = [pattern for pattern in patterns if pattern.kind == "REPEATED_TABLE_READ"]
assert repeated_reads
assert repeated_reads[0].support == 2
+12
View File
@@ -0,0 +1,12 @@
# sfera-projection-engine
Projection layer for SIR snapshots.
Provides:
- in-memory graph projection for tests and API queries;
- Neo4j snapshot projection;
- Neo4j delta projection from `SirDelta`;
- routine graph queries: procedures, callers, callees, query tables, writes.
Only this package should mutate Neo4j.
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-projection-engine"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"neo4j>=5.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,237 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
import json
from sir import EdgeKind, SemanticEdge, SemanticNode, SirDelta, SirSnapshot
@dataclass
class InMemoryProjection:
nodes: dict[str, SemanticNode] = field(default_factory=dict)
edges: dict[str, SemanticEdge] = field(default_factory=dict)
def project_snapshot(self, snapshot: SirSnapshot) -> None:
self.nodes = {node.lineage_id: node for node in snapshot.nodes}
self.edges = {edge.edge_id: edge for edge in snapshot.edges}
def apply_delta(self, delta: SirDelta) -> None:
for lineage_id in delta.removed_nodes:
self.nodes.pop(lineage_id, None)
self.edges = {
edge_id: edge
for edge_id, edge in self.edges.items()
if edge.source_lineage != lineage_id and edge.target_lineage != lineage_id
}
for edge_id in delta.removed_edges:
self.edges.pop(edge_id, None)
for node in [*delta.added_nodes, *delta.updated_nodes]:
self.nodes[node.lineage_id] = node
for edge in delta.added_edges:
self.edges[edge.edge_id] = edge
def find_procedures(self) -> list[SemanticNode]:
return [
node
for node in self.nodes.values()
if node.kind.value in {"PROCEDURE", "FUNCTION"}
]
def find_callers(self, routine_name: str) -> list[SemanticNode]:
target_ids = self._routine_lineages(routine_name)
caller_ids = [
edge.source_lineage
for edge in self.edges.values()
if edge.kind == EdgeKind.CALLS and edge.target_lineage in target_ids
]
return [self.nodes[lineage] for lineage in caller_ids if lineage in self.nodes]
def find_callees(self, routine_name: str) -> list[SemanticNode]:
source_ids = self._routine_lineages(routine_name)
callee_ids = [
edge.target_lineage
for edge in self.edges.values()
if edge.kind == EdgeKind.CALLS and edge.source_lineage in source_ids
]
return [self.nodes[lineage] for lineage in callee_ids if lineage in self.nodes]
def find_query_tables(self, routine_name: str) -> list[SemanticNode]:
routine_ids = self._routine_lineages(routine_name)
query_ids = [
edge.target_lineage
for edge in self.edges.values()
if edge.kind == EdgeKind.OWNS_QUERY and edge.source_lineage in routine_ids
]
table_ids = [
edge.target_lineage
for edge in self.edges.values()
if edge.kind == EdgeKind.READS_TABLE and edge.source_lineage in query_ids
]
return [self.nodes[lineage] for lineage in table_ids if lineage in self.nodes]
def find_writes(self, routine_name: str) -> list[SemanticNode]:
routine_ids = self._routine_lineages(routine_name)
write_ids = [
edge.target_lineage
for edge in self.edges.values()
if edge.kind == EdgeKind.WRITES and edge.source_lineage in routine_ids
]
return [self.nodes[lineage] for lineage in write_ids if lineage in self.nodes]
def _routine_lineages(self, routine_name: str) -> set[str]:
wanted = routine_name.lower()
return {
node.lineage_id
for node in self.nodes.values()
if node.name.lower() == wanted and node.kind.value in {"PROCEDURE", "FUNCTION"}
}
class Neo4jProjection:
def __init__(self, driver) -> None:
self._driver = driver
async def ensure_schema(self) -> None:
async with self._driver.session() as session:
await session.run(
"""
CREATE CONSTRAINT sfera_node_lineage IF NOT EXISTS
FOR (n:SferaNode) REQUIRE n.lineage_id IS UNIQUE
"""
)
await session.run(
"""
CREATE INDEX sfera_node_project_name IF NOT EXISTS
FOR (n:SferaNode) ON (n.project_id, n.name)
"""
)
await session.run(
"""
CREATE INDEX sfera_edge_kind IF NOT EXISTS
FOR ()-[r:SEMANTIC_EDGE]-() ON (r.kind)
"""
)
async def project_snapshot(self, snapshot: SirSnapshot) -> None:
await self.ensure_schema()
async with self._driver.session() as session:
for node in snapshot.nodes:
await self._merge_node(session, node, snapshot.project_id)
for edge in snapshot.edges:
await self._merge_edge(session, edge)
async def apply_delta(self, delta: SirDelta, *, project_id: str) -> None:
await self.ensure_schema()
async with self._driver.session() as session:
for lineage_id in delta.removed_nodes:
await session.run(
"""
MATCH (n:SferaNode {project_id: $project_id, lineage_id: $lineage_id})
DETACH DELETE n
""",
project_id=project_id,
lineage_id=lineage_id,
)
for edge_id in delta.removed_edges:
await session.run(
"""
MATCH ()-[r:SEMANTIC_EDGE {edge_id: $edge_id}]->()
DELETE r
""",
edge_id=edge_id,
)
for node in [*delta.added_nodes, *delta.updated_nodes]:
await self._merge_node(session, node, project_id)
for edge in delta.added_edges:
await self._merge_edge(session, edge)
async def clear_project(self, project_id: str) -> None:
async with self._driver.session() as session:
await session.run(
"""
MATCH (n:SferaNode {project_id: $project_id})
DETACH DELETE n
""",
project_id=project_id,
)
async def counts(self, project_id: str | None = None) -> dict[str, int]:
async with self._driver.session() as session:
if project_id is None:
node_result = await session.run("MATCH (n:SferaNode) RETURN count(n) AS count")
edge_result = await session.run(
"MATCH ()-[r:SEMANTIC_EDGE]->() RETURN count(r) AS count"
)
else:
node_result = await session.run(
"""
MATCH (n:SferaNode {project_id: $project_id})
RETURN count(n) AS count
""",
project_id=project_id,
)
edge_result = await session.run(
"""
MATCH (:SferaNode {project_id: $project_id})-[r:SEMANTIC_EDGE]->
(:SferaNode {project_id: $project_id})
RETURN count(r) AS count
""",
project_id=project_id,
)
node_record = await node_result.single()
edge_record = await edge_result.single()
return {
"nodes": int(node_record["count"]) if node_record else 0,
"edges": int(edge_record["count"]) if edge_record else 0,
}
async def _merge_node(self, session, node: SemanticNode, project_id: str) -> None:
await session.run(
"""
MERGE (n:SferaNode {lineage_id: $lineage_id})
SET n.semantic_id = $semantic_id,
n.project_id = $project_id,
n.kind = $kind,
n.name = $name,
n.qualified_name = $qualified_name,
n.attributes_json = $attributes_json,
n.source_ref_json = $source_ref_json
""",
lineage_id=node.lineage_id,
semantic_id=node.semantic_id,
project_id=project_id,
kind=node.kind.value,
name=node.name,
qualified_name=node.qualified_name,
attributes_json=json.dumps(node.attributes, ensure_ascii=False, sort_keys=True),
source_ref_json=node.source_ref.model_dump_json(exclude_none=True),
)
async def _merge_edge(self, session, edge: SemanticEdge) -> None:
await session.run(
"""
MATCH (source:SferaNode {lineage_id: $source_lineage})
MATCH (target:SferaNode {lineage_id: $target_lineage})
MERGE (source)-[r:SEMANTIC_EDGE {edge_id: $edge_id}]->(target)
SET r.kind = $kind,
r.attributes_json = $attributes_json,
r.source_ref_json = $source_ref_json
""",
source_lineage=edge.source_lineage,
target_lineage=edge.target_lineage,
edge_id=edge.edge_id,
kind=edge.kind.value,
attributes_json=json.dumps(edge.attributes, ensure_ascii=False, sort_keys=True),
source_ref_json=edge.source_ref.model_dump_json(exclude_none=True) if edge.source_ref else None,
)
def build_adjacency(snapshot: SirSnapshot) -> dict[str, list[SemanticEdge]]:
adjacency: dict[str, list[SemanticEdge]] = defaultdict(list)
for edge in snapshot.edges:
adjacency[edge.source_lineage].append(edge)
return dict(adjacency)
__all__ = ["InMemoryProjection", "Neo4jProjection", "build_adjacency"]
@@ -0,0 +1,98 @@
from pathlib import Path
from projection_engine import InMemoryProjection, Neo4jProjection
from semantic_kernel import index_project
from sir import SirDelta
def test_projection_queries(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ПроверитьОстатки()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Остатки.Номенклатура
ИЗ
РегистрНакопления.ОстаткиТоваров КАК Остатки";
КонецПроцедуры
""",
encoding="utf-8",
)
graph = InMemoryProjection()
graph.project_snapshot(index_project(tmp_path, project_id="demo"))
assert [node.name for node in graph.find_callees("Проведение")] == ["ПроверитьОстатки"]
assert [node.name for node in graph.find_callers("ПроверитьОстатки")] == ["Проведение"]
assert [node.name for node in graph.find_writes("Проведение")] == ["ОстаткиТоваров"]
assert [node.qualified_name for node in graph.find_query_tables("ПроверитьОстатки")] == [
"РегистрНакопления.ОстаткиТоваров"
]
class FakeAsyncResult:
async def single(self):
return {"count": 0}
class FakeSession:
def __init__(self) -> None:
self.runs: list[tuple[str, dict]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def run(self, query: str, **parameters):
self.runs.append((query, parameters))
return FakeAsyncResult()
class FakeDriver:
def __init__(self) -> None:
self.session_instance = FakeSession()
def session(self):
return self.session_instance
async def test_neo4j_projection_applies_delta(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text("Процедура Проведение()\nКонецПроцедуры\n", encoding="utf-8")
previous = index_project(tmp_path, project_id="demo-delta")
module.write_text(
"""
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
current = index_project(tmp_path, project_id="demo-delta")
delta = SirDelta(
delta_id="delta.test",
snapshot_from=previous.snapshot_id,
snapshot_to=current.snapshot_id,
added_nodes=[node for node in current.nodes if node.lineage_id not in {old.lineage_id for old in previous.nodes}],
updated_nodes=[],
removed_nodes=[],
added_edges=[edge for edge in current.edges if edge.edge_id not in {old.edge_id for old in previous.edges}],
removed_edges=[],
)
driver = FakeDriver()
await Neo4jProjection(driver).apply_delta(delta, project_id="demo-delta")
parameter_sets = [parameters for _query, parameters in driver.session_instance.runs]
assert any(parameters.get("project_id") == "demo-delta" for parameters in parameter_sets)
assert any(parameters.get("name") == "ОстаткиТоваров" for parameters in parameter_sets)
assert any(parameters.get("kind") == "WRITES" for parameters in parameter_sets)
+3
View File
@@ -0,0 +1,3 @@
# sfera-query-intelligence
Query/table usage summaries over SIR snapshots.
@@ -0,0 +1,11 @@
[project]
name = "sfera-query-intelligence"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,62 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from sir import EdgeKind, SemanticNode, SirSnapshot
class TableUsage(BaseModel):
table: SemanticNode
queries: list[SemanticNode] = Field(default_factory=list)
readers: list[SemanticNode] = Field(default_factory=list)
writers: list[SemanticNode] = Field(default_factory=list)
@property
def has_read_write_conflict(self) -> bool:
return bool(self.readers and self.writers)
def table_usage(snapshot: SirSnapshot, table_name: str | None = None) -> list[TableUsage]:
nodes = {node.lineage_id: node for node in snapshot.nodes}
query_owner: dict[str, SemanticNode] = {}
for edge in snapshot.edges:
if edge.kind == EdgeKind.OWNS_QUERY and edge.target_lineage in nodes and edge.source_lineage in nodes:
query_owner[edge.target_lineage] = nodes[edge.source_lineage]
usage_by_table: dict[str, TableUsage] = {}
for edge in snapshot.edges:
if edge.kind != EdgeKind.READS_TABLE:
continue
query = nodes.get(edge.source_lineage)
table = nodes.get(edge.target_lineage)
if query is None or table is None:
continue
if table_name is not None and table.name.casefold() != table_name.casefold() and table.qualified_name.casefold() != table_name.casefold():
continue
usage = usage_by_table.setdefault(table.lineage_id, TableUsage(table=table))
usage.queries.append(query)
if owner := query_owner.get(query.lineage_id):
usage.readers.append(owner)
for edge in snapshot.edges:
if edge.kind != EdgeKind.WRITES:
continue
writer = nodes.get(edge.source_lineage)
table = nodes.get(edge.target_lineage)
if writer is None or table is None:
continue
if table_name is not None and table.name.casefold() != table_name.casefold() and table.qualified_name.casefold() != table_name.casefold():
continue
usage = usage_by_table.setdefault(table.lineage_id, TableUsage(table=table))
usage.writers.append(writer)
result = list(usage_by_table.values())
result.sort(key=lambda item: item.table.qualified_name)
return result
def tables_with_read_write_conflicts(snapshot: SirSnapshot) -> list[TableUsage]:
return [usage for usage in table_usage(snapshot) if usage.has_read_write_conflict]
__all__ = ["TableUsage", "table_usage", "tables_with_read_write_conflicts"]
@@ -0,0 +1,72 @@
from pathlib import Path
from query_intelligence import table_usage, tables_with_read_write_conflicts
from semantic_kernel import index_project
def test_table_usage_finds_query_readers(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура ПроверитьОстатки()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Остатки.Номенклатура
ИЗ
РегистрНакопления.ОстаткиТоваров КАК Остатки";
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="demo")
usage = table_usage(snapshot, "РегистрНакопления.ОстаткиТоваров")
assert usage[0].table.name == "ОстаткиТоваров"
assert usage[0].readers[0].name == "ПроверитьОстатки"
def test_table_usage_finds_writers(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="demo")
usage = table_usage(snapshot, "ОстаткиТоваров")
assert usage[0].writers[0].name == "Проведение"
def test_tables_with_read_write_conflicts_find_register_dependencies(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура ПроверитьОстатки()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Остатки.Номенклатура
ИЗ
РегистрНакопления.ОстаткиТоваров КАК Остатки";
КонецПроцедуры
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="query-conflicts")
conflicts = tables_with_read_write_conflicts(snapshot)
assert [usage.table.qualified_name for usage in conflicts] == ["РегистрНакопления.ОстаткиТоваров"]
assert [node.name for node in conflicts[0].readers] == ["ПроверитьОстатки"]
assert [node.name for node in conflicts[0].writers] == ["Проведение"]
+12
View File
@@ -0,0 +1,12 @@
# sfera-review-engine
Deterministic review findings over SIR snapshots.
Checks include:
- parser diagnostics and unresolved references;
- missing 1C role grants on root objects;
- unresolved form command handlers;
- empty tabular sections and weak tabular-section schemas;
- document posting routines without register writes;
- outbound integration endpoints.
+12
View File
@@ -0,0 +1,12 @@
[project]
name = "sfera-review-engine"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-integration-topology",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,314 @@
from __future__ import annotations
from pydantic import BaseModel
from integration_topology import IntegrationKind, build_integration_topology
from query_intelligence import tables_with_read_write_conflicts
from sir import DiagnosticSeverity, EdgeKind, NodeKind, SirSnapshot
class ReviewFinding(BaseModel):
finding_id: str
title: str
severity: DiagnosticSeverity
message: str
source_path: str | None = None
line_start: int | None = None
def review_snapshot(snapshot: SirSnapshot) -> list[ReviewFinding]:
findings: list[ReviewFinding] = []
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
for diagnostic in snapshot.diagnostics:
findings.append(
ReviewFinding(
finding_id=f"finding.{diagnostic.diagnostic_id}",
title=diagnostic.code,
severity=diagnostic.severity,
message=diagnostic.message,
source_path=diagnostic.source_ref.source_path if diagnostic.source_ref else None,
line_start=diagnostic.source_ref.line_start if diagnostic.source_ref else None,
)
)
for reference in snapshot.unresolved_references:
findings.append(
ReviewFinding(
finding_id=f"finding.{reference.reference_id}",
title=f"Unresolved {reference.kind.value.lower()}",
severity=DiagnosticSeverity.WARNING,
message=f"Unresolved reference to {reference.target_name}",
source_path=reference.source_ref.source_path,
line_start=reference.source_ref.line_start,
)
)
secured_lineages = {
edge.target_lineage
for edge in snapshot.edges
if edge.kind == EdgeKind.GRANTS_ACCESS
}
access_target_kinds = {
NodeKind.CATALOG,
NodeKind.DOCUMENT,
NodeKind.REGISTER,
NodeKind.COMMON_MODULE,
NodeKind.EXCHANGE_PLAN,
NodeKind.SCHEDULED_JOB,
NodeKind.BUSINESS_PROCESS,
NodeKind.TASK,
}
for node in snapshot.nodes:
if node.kind in access_target_kinds and node.lineage_id not in secured_lineages:
findings.append(
ReviewFinding(
finding_id=f"finding.security.unsecured.{node.lineage_id}",
title="Missing 1C role access",
severity=DiagnosticSeverity.WARNING,
message=f"Object {node.qualified_name} has no role access grants",
source_path=node.source_ref.source_path,
line_start=node.source_ref.line_start,
)
)
handled_command_lineages = {
edge.source_lineage
for edge in snapshot.edges
if edge.kind == EdgeKind.HANDLES
}
for node in snapshot.nodes:
if node.kind != NodeKind.COMMAND or node.lineage_id in handled_command_lineages:
continue
handler_name = _command_handler_name(node.attributes)
if not handler_name:
continue
findings.append(
ReviewFinding(
finding_id=f"finding.ui.unresolved_handler.{node.lineage_id}",
title="Unresolved 1C command handler",
severity=DiagnosticSeverity.WARNING,
message=f"Command {node.qualified_name} references missing handler {handler_name}",
source_path=node.source_ref.source_path,
line_start=node.source_ref.line_start,
)
)
handled_form_handlers = {
(edge.source_lineage, str(edge.attributes.get("handler_name", "")).casefold())
for edge in snapshot.edges
if edge.kind == EdgeKind.HANDLES
}
for node in snapshot.nodes:
if node.kind != NodeKind.FORM:
continue
for _, handler_name in _form_handler_names(node.attributes):
if (node.lineage_id, handler_name.casefold()) in handled_form_handlers:
continue
findings.append(
ReviewFinding(
finding_id=f"finding.ui.unresolved_form_handler.{node.lineage_id}.{handler_name}",
title="Unresolved 1C form event handler",
severity=DiagnosticSeverity.WARNING,
message=f"Form {node.qualified_name} references missing event handler {handler_name}",
source_path=node.source_ref.source_path,
line_start=node.source_ref.line_start,
)
)
findings.extend(_document_posting_write_review(snapshot, nodes_by_lineage))
tabular_sections_with_columns = {
edge.source_lineage
for edge in snapshot.edges
if edge.kind == EdgeKind.HAS_ATTRIBUTE
}
tabular_section_columns: dict[str, list] = {}
for edge in snapshot.edges:
if edge.kind != EdgeKind.HAS_ATTRIBUTE:
continue
source = nodes_by_lineage.get(edge.source_lineage)
target = nodes_by_lineage.get(edge.target_lineage)
if source is None or target is None:
continue
if source.kind == NodeKind.TABULAR_SECTION and target.kind == NodeKind.ATTRIBUTE:
tabular_section_columns.setdefault(source.lineage_id, []).append(target)
for node in snapshot.nodes:
if node.kind != NodeKind.TABULAR_SECTION or node.lineage_id in tabular_sections_with_columns:
continue
findings.append(
ReviewFinding(
finding_id=f"finding.schema.empty_tabular_section.{node.lineage_id}",
title="Empty 1C tabular section",
severity=DiagnosticSeverity.WARNING,
message=f"Tabular section {node.qualified_name} has no columns",
source_path=node.source_ref.source_path,
line_start=node.source_ref.line_start,
)
)
for node in snapshot.nodes:
if node.kind != NodeKind.TABULAR_SECTION:
continue
columns = tabular_section_columns.get(node.lineage_id, [])
if not columns or _has_subject_column(columns):
continue
findings.append(
ReviewFinding(
finding_id=f"finding.schema.no_subject_column.{node.lineage_id}",
title="No subject column in 1C tabular section",
severity=DiagnosticSeverity.INFO,
message=(
f"Tabular section {node.qualified_name} has columns, "
"but no common subject column such as Номенклатура, Товар, Услуга, Объект, or Ссылка"
),
source_path=node.source_ref.source_path,
line_start=node.source_ref.line_start,
)
)
for endpoint in build_integration_topology(snapshot).endpoints:
if endpoint.direction == "OUTBOUND" and endpoint.kind in {
IntegrationKind.HTTP_SERVICE,
IntegrationKind.WEB_SERVICE,
IntegrationKind.COM_CONNECTOR,
}:
findings.append(
ReviewFinding(
finding_id=f"finding.integration.{endpoint.endpoint_id}",
title="External integration endpoint",
severity=DiagnosticSeverity.INFO,
message=f"{endpoint.kind.value} integration {endpoint.name} is used by {endpoint.owner}",
source_path=None,
line_start=None,
)
)
for usage in tables_with_read_write_conflicts(snapshot):
findings.append(
ReviewFinding(
finding_id=f"finding.query.read_write_conflict.{usage.table.lineage_id}",
title="Register read/write dependency",
severity=DiagnosticSeverity.INFO,
message=(
f"{usage.table.qualified_name} is read by "
f"{', '.join(reader.name for reader in usage.readers)} and written by "
f"{', '.join(writer.name for writer in usage.writers)}"
),
source_path=usage.table.source_ref.source_path,
line_start=usage.table.source_ref.line_start,
)
)
return findings
def _document_posting_write_review(
snapshot: SirSnapshot,
nodes_by_lineage: dict[str, object],
) -> list[ReviewFinding]:
module_lineages_by_document = {
edge.source_lineage: edge.target_lineage
for edge in snapshot.edges
if edge.kind == EdgeKind.CONTAINS
and nodes_by_lineage.get(edge.source_lineage) is not None
and getattr(nodes_by_lineage[edge.source_lineage], "kind", None) == NodeKind.DOCUMENT
and nodes_by_lineage.get(edge.target_lineage) is not None
and getattr(nodes_by_lineage[edge.target_lineage], "kind", None) == NodeKind.MODULE
}
routines_by_module: dict[str, list] = {}
routines_with_writes = {
edge.source_lineage
for edge in snapshot.edges
if edge.kind == EdgeKind.WRITES
}
for edge in snapshot.edges:
if edge.kind != EdgeKind.DECLARES:
continue
routine = nodes_by_lineage.get(edge.target_lineage)
if routine is None or getattr(routine, "kind", None) not in {NodeKind.PROCEDURE, NodeKind.FUNCTION}:
continue
routines_by_module.setdefault(edge.source_lineage, []).append(routine)
findings: list[ReviewFinding] = []
for document_lineage, module_lineage in module_lineages_by_document.items():
document = nodes_by_lineage[document_lineage]
posting_routines = [
routine
for routine in routines_by_module.get(module_lineage, [])
if routine.name.casefold() in {"проведение", "posting"}
]
for routine in posting_routines:
if routine.lineage_id in routines_with_writes:
continue
findings.append(
ReviewFinding(
finding_id=f"finding.document.posting_no_writes.{routine.lineage_id}",
title="Document posting has no register writes",
severity=DiagnosticSeverity.INFO,
message=(
f"Document {document.qualified_name} posting routine "
f"{routine.name} does not write registers"
),
source_path=routine.source_ref.source_path,
line_start=routine.source_ref.line_start,
)
)
return findings
def _command_handler_name(attributes: dict) -> str:
for key in (
"action",
"Action",
"handler",
"Handler",
"method",
"Method",
"methodName",
"MethodName",
"Действие",
"Обработчик",
"Метод",
"ИмяМетода",
):
value = attributes.get(key)
if value:
return str(value).split(".")[-1]
return ""
def _form_handler_names(attributes: dict) -> list[tuple[str, str]]:
handler_keys = {
"oncreate",
"onopen",
"onclose",
"beforeclose",
"beforewrite",
"afterwrite",
"onread",
"onchange",
"event",
"handler",
"method",
"methodname",
"присозданиинсервере",
"присозданиинаклиенте",
"приоткрытии",
"передзакрытием",
"призакрытии",
"передзаписью",
"призаписи",
"причтении",
"приизменении",
"событие",
"обработчик",
"метод",
"имяметода",
}
handlers: list[tuple[str, str]] = []
for key, value in attributes.items():
if value and str(key).casefold() in handler_keys:
handlers.append((str(key), str(value).split(".")[-1]))
return handlers
def _has_subject_column(columns: list) -> bool:
subject_names = {"номенклатура", "товар", "товары", "услуга", "услуги", "объект", "ссылка"}
for column in columns:
name = column.name.casefold()
if name in subject_names:
return True
return False
__all__ = ["ReviewFinding", "review_snapshot"]
+246
View File
@@ -0,0 +1,246 @@
from review_engine import review_snapshot
from semantic_kernel import index_project
from sir import ReferenceKind, SirSnapshot, SourceRef, UnresolvedReference
def test_review_snapshot_reports_unresolved_references():
snapshot = SirSnapshot(
snapshot_id="snapshot.demo",
project_id="demo",
unresolved_references=[
UnresolvedReference(
reference_id="ref.1",
kind=ReferenceKind.CALL,
source_lineage="lineage.procedure.demo",
target_name="Missing",
source_ref=SourceRef(source_path="module.bsl", line_start=2),
)
],
)
findings = review_snapshot(snapshot)
assert findings[0].title == "Unresolved call"
assert findings[0].source_path == "module.bsl"
def test_review_snapshot_reports_1c_objects_without_role_access(tmp_path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="review-security")
findings = review_snapshot(snapshot)
assert findings[0].title == "Missing 1C role access"
assert "Документ.ЗаказПокупателя" in findings[0].message
def test_review_snapshot_reports_external_integrations(tmp_path):
module = tmp_path / "integration.bsl"
module.write_text(
"""
Процедура Отправить()
Адрес = "https://api.example.local/orders";
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="review-integrations")
findings = review_snapshot(snapshot)
assert any(finding.title == "External integration endpoint" for finding in findings)
def test_review_snapshot_reports_unresolved_1c_command_handler(tmp_path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" action="ПровестиКоманда" />
</Form>
</Document>
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="review-ui-handler")
findings = review_snapshot(snapshot)
assert any(
finding.title == "Unresolved 1C command handler"
and "ПровестиКоманда" in finding.message
for finding in findings
)
def test_review_snapshot_reports_unresolved_1c_form_event_handler(tmp_path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Form
name="ФормаДокумента"
qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента"
onCreate="ПриСозданииНаСервере"
beforeWrite="ПередЗаписью"
/>
</Document>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "form_module.bsl"
module.write_text("Процедура ПриСозданииНаСервере()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="review-form-handler")
findings = review_snapshot(snapshot)
assert any(
finding.title == "Unresolved 1C form event handler"
and "ПередЗаписью" in finding.message
for finding in findings
)
assert not any(
finding.title == "Unresolved 1C form event handler"
and "ПриСозданииНаСервере" in finding.message
for finding in findings
)
def test_review_snapshot_reports_empty_1c_tabular_section(tmp_path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<TabularSection name="Услуги" qualifiedName="Документ.ЗаказПокупателя.Услуги" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="review-empty-tabular-section")
findings = review_snapshot(snapshot)
assert any(
finding.title == "Empty 1C tabular section"
and "Документ.ЗаказПокупателя.Услуги" in finding.message
for finding in findings
)
def test_review_snapshot_reports_tabular_section_without_subject_column(tmp_path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Количество" qualifiedName="Документ.ЗаказПокупателя.Товары.Количество" />
</TabularSection>
</Document>
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="review-no-subject-column")
findings = review_snapshot(snapshot)
assert any(
finding.title == "No subject column in 1C tabular section"
and finding.severity.value == "INFO"
and "Документ.ЗаказПокупателя.Товары" in finding.message
for finding in findings
)
def test_review_snapshot_accepts_tabular_section_with_subject_column(tmp_path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
<Attribute name="Количество" qualifiedName="Документ.ЗаказПокупателя.Товары.Количество" />
</TabularSection>
</Document>
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="review-subject-column")
findings = review_snapshot(snapshot)
assert not any(finding.title == "No subject column in 1C tabular section" for finding in findings)
def test_review_snapshot_reports_document_posting_without_register_writes(tmp_path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text("Процедура Проведение()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="review-document-posting")
findings = review_snapshot(snapshot)
assert any(
finding.title == "Document posting has no register writes"
and finding.severity.value == "INFO"
and "Документ.ЗаказПокупателя" in finding.message
for finding in findings
)
def test_review_snapshot_reports_register_read_write_dependency(tmp_path):
module = tmp_path / "module.bsl"
module.write_text(
"""
Процедура ПроверитьОстатки()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Остатки.Номенклатура
ИЗ
РегистрНакопления.ОстаткиТоваров КАК Остатки";
КонецПроцедуры
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="review-query-conflict")
findings = review_snapshot(snapshot)
assert any(
finding.title == "Register read/write dependency"
and "ПроверитьОстатки" in finding.message
and "Проведение" in finding.message
for finding in findings
)
+3
View File
@@ -0,0 +1,3 @@
# sfera-runtime-overlays
Runtime observations attached to semantic lineage IDs.
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-runtime-overlays"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,69 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
from sir import SemanticNode, SirSnapshot
class RuntimeSignalKind(str, Enum):
EXECUTION = "EXECUTION"
ERROR = "ERROR"
SLOW_QUERY = "SLOW_QUERY"
WRITE = "WRITE"
class RuntimeSignal(BaseModel):
signal_id: str
lineage_id: str
kind: RuntimeSignalKind
observed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
count: int = 1
duration_ms: float | None = None
message: str | None = None
attributes: dict = Field(default_factory=dict)
class RuntimeOverlay(BaseModel):
project_id: str
signals: list[RuntimeSignal] = Field(default_factory=list)
class NodeRuntimeSummary(BaseModel):
node: SemanticNode
signal_count: int
error_count: int
max_duration_ms: float | None = None
def summarize_runtime(snapshot: SirSnapshot, overlay: RuntimeOverlay) -> list[NodeRuntimeSummary]:
nodes = {node.lineage_id: node for node in snapshot.nodes}
grouped: dict[str, list[RuntimeSignal]] = {}
for signal in overlay.signals:
if signal.lineage_id in nodes:
grouped.setdefault(signal.lineage_id, []).append(signal)
summaries: list[NodeRuntimeSummary] = []
for lineage_id, signals in grouped.items():
durations = [signal.duration_ms for signal in signals if signal.duration_ms is not None]
summaries.append(
NodeRuntimeSummary(
node=nodes[lineage_id],
signal_count=sum(signal.count for signal in signals),
error_count=sum(signal.count for signal in signals if signal.kind == RuntimeSignalKind.ERROR),
max_duration_ms=max(durations) if durations else None,
)
)
summaries.sort(key=lambda item: (-item.error_count, -(item.max_duration_ms or 0), item.node.qualified_name))
return summaries
__all__ = [
"NodeRuntimeSummary",
"RuntimeOverlay",
"RuntimeSignal",
"RuntimeSignalKind",
"summarize_runtime",
]
@@ -0,0 +1,27 @@
from pathlib import Path
from runtime_overlays import RuntimeOverlay, RuntimeSignal, RuntimeSignalKind, summarize_runtime
from semantic_kernel import index_project
def test_summarize_runtime_attaches_signals_to_nodes(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text("Процедура Проведение()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="demo")
routine = next(node for node in snapshot.nodes if node.name == "Проведение")
overlay = RuntimeOverlay(
project_id="demo",
signals=[
RuntimeSignal(
signal_id="signal.1",
lineage_id=routine.lineage_id,
kind=RuntimeSignalKind.ERROR,
duration_ms=120.0,
)
],
)
summaries = summarize_runtime(snapshot, overlay)
assert summaries[0].node.name == "Проведение"
assert summaries[0].error_count == 1
+11
View File
@@ -0,0 +1,11 @@
# sfera-security-core
Security primitives for SFERA.
Provides:
- RBAC roles, grants, permission checks, and effective permissions;
- default admin/developer/viewer policy;
- privacy modes and classifications;
- project/target scoped privacy markers;
- AI usage policy configuration.
+10
View File
@@ -0,0 +1,10 @@
[project]
name = "sfera-security-core"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
]
[tool.uv]
package = true
@@ -0,0 +1,147 @@
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
class Permission(str, Enum):
INDEX_PROJECT = "INDEX_PROJECT"
READ_GRAPH = "READ_GRAPH"
WRITE_KNOWLEDGE = "WRITE_KNOWLEDGE"
MANAGE_TASKS = "MANAGE_TASKS"
MANAGE_USERS = "MANAGE_USERS"
ADMIN = "ADMIN"
class PrivacyMode(str, Enum):
LOCAL_ONLY = "LOCAL_ONLY"
WORKSPACE_SHARED = "WORKSPACE_SHARED"
EXTERNAL_ALLOWED = "EXTERNAL_ALLOWED"
class PrivacyClassification(str, Enum):
PUBLIC = "PUBLIC"
INTERNAL = "INTERNAL"
CONFIDENTIAL = "CONFIDENTIAL"
PERSONAL_DATA = "PERSONAL_DATA"
SECRET = "SECRET"
class PrivacyMarker(BaseModel):
project_id: str
target_id: str
classification: PrivacyClassification
reason: str | None = None
attributes: dict = Field(default_factory=dict)
class Role(BaseModel):
role_id: str
name: str
permissions: set[Permission] = Field(default_factory=set)
class UserAccess(BaseModel):
user_id: str
roles: set[str] = Field(default_factory=set)
class RbacPolicy(BaseModel):
roles: dict[str, Role] = Field(default_factory=dict)
users: dict[str, UserAccess] = Field(default_factory=dict)
def grant_role(self, user_id: str, role_id: str) -> None:
if role_id not in self.roles:
raise KeyError(f"Unknown role: {role_id}")
access = self.users.setdefault(user_id, UserAccess(user_id=user_id))
access.roles.add(role_id)
def is_allowed(self, user_id: str, permission: Permission) -> bool:
access = self.users.get(user_id)
if access is None:
return False
for role_id in access.roles:
role = self.roles.get(role_id)
if role and (Permission.ADMIN in role.permissions or permission in role.permissions):
return True
return False
def effective_permissions(self, user_id: str) -> set[Permission]:
access = self.users.get(user_id)
if access is None:
return set()
permissions: set[Permission] = set()
for role_id in access.roles:
role = self.roles.get(role_id)
if role is None:
continue
if Permission.ADMIN in role.permissions:
return set(Permission)
permissions.update(role.permissions)
return permissions
class AiUsagePolicy(BaseModel):
privacy_mode: PrivacyMode = PrivacyMode.LOCAL_ONLY
allow_code_context: bool = False
allow_external_calls: bool = False
token_limit_per_day: int | None = None
class InMemoryPrivacyStore:
def __init__(self) -> None:
self.markers: dict[str, PrivacyMarker] = {}
def upsert_marker(self, marker: PrivacyMarker) -> PrivacyMarker:
self.markers[self._key(marker.project_id, marker.target_id)] = marker
return marker
def markers_for_project(self, project_id: str) -> list[PrivacyMarker]:
return sorted(
[
marker
for marker in self.markers.values()
if marker.project_id == project_id
],
key=lambda item: item.target_id,
)
def marker_for_target(self, project_id: str, target_id: str) -> PrivacyMarker | None:
return self.markers.get(self._key(project_id, target_id))
def _key(self, project_id: str, target_id: str) -> str:
return f"{project_id}:{target_id}"
def default_rbac_policy() -> RbacPolicy:
return RbacPolicy(
roles={
"admin": Role(role_id="admin", name="Administrator", permissions={Permission.ADMIN}),
"developer": Role(
role_id="developer",
name="Developer",
permissions={
Permission.INDEX_PROJECT,
Permission.READ_GRAPH,
Permission.WRITE_KNOWLEDGE,
Permission.MANAGE_TASKS,
},
),
"viewer": Role(role_id="viewer", name="Viewer", permissions={Permission.READ_GRAPH}),
}
)
__all__ = [
"AiUsagePolicy",
"InMemoryPrivacyStore",
"Permission",
"PrivacyClassification",
"PrivacyMarker",
"PrivacyMode",
"RbacPolicy",
"Role",
"UserAccess",
"default_rbac_policy",
]
@@ -0,0 +1,43 @@
from security_core import (
InMemoryPrivacyStore,
Permission,
PrivacyClassification,
PrivacyMarker,
default_rbac_policy,
)
def test_rbac_allows_permissions_from_granted_role():
policy = default_rbac_policy()
policy.grant_role("user.1", "developer")
assert policy.is_allowed("user.1", Permission.INDEX_PROJECT)
assert not policy.is_allowed("user.1", Permission.MANAGE_USERS)
assert policy.effective_permissions("user.1") == {
Permission.INDEX_PROJECT,
Permission.READ_GRAPH,
Permission.WRITE_KNOWLEDGE,
Permission.MANAGE_TASKS,
}
def test_admin_effective_permissions_expand_to_all_permissions():
policy = default_rbac_policy()
policy.grant_role("user.1", "admin")
assert policy.effective_permissions("user.1") == set(Permission)
def test_privacy_store_is_project_and_target_scoped():
store = InMemoryPrivacyStore()
marker = store.upsert_marker(
PrivacyMarker(
project_id="demo",
target_id="lineage.attribute.phone",
classification=PrivacyClassification.PERSONAL_DATA,
reason="Phone number",
)
)
assert store.markers_for_project("demo") == [marker]
assert store.marker_for_target("demo", "lineage.attribute.phone") == marker
+11
View File
@@ -0,0 +1,11 @@
# sfera-semantic-kernel
Semantic indexing kernel for 1C projects.
Provides:
- BSL module indexing into SIR;
- Rust BSL parser JSON contract consumption when the CLI is configured or auto-discovered;
- Python BSL parser fallback for development and tests;
- XML metadata indexing for core 1C objects;
- metadata-to-module links and semantic edges for calls, queries, writes, handlers, scheduled jobs, roles, and integrations.
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-semantic-kernel"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"sfera-one-c-normalizer",
"sfera-sir",
]
[tool.uv]
package = true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
from pathlib import Path
import subprocess
import semantic_kernel
from semantic_kernel import index_project, parse_bsl_module, parse_bsl_module_from_rust_json
from sir import EdgeKind, NodeKind
def test_parse_bsl_module_supports_english_1c_syntax() -> None:
source = Path("tests/golden/english_module.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert [routine.name for routine in routines] == ["Posting", "CheckStock"]
assert routines[1].is_function
assert routines[0].calls == (("CheckStock", 2),)
assert routines[0].writes[0].target == "StockBalance"
assert routines[1].queries[0].tables == ("AccumulationRegister.StockBalance",)
def test_parse_bsl_module_supports_inline_query_assignment_with_pipes() -> None:
source = Path("tests/golden/query_inline_pipes.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert len(routines) == 1
assert routines[0].queries[0].tables == ("Справочник.Номенклатура",)
assert routines[0].queries[0].text.startswith("ВЫБРАТЬ")
assert "|ИЗ" not in routines[0].queries[0].text
def test_parse_bsl_module_supports_from_and_table_on_same_line() -> None:
source = Path("tests/golden/query_inline_from.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert len(routines) == 1
assert routines[0].queries[0].tables == ("Справочник.Контрагенты",)
def test_parse_bsl_module_extracts_assignment_function_calls() -> None:
source = Path("tests/golden/assignment_call.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert routines[0].calls == (("ПроверитьОстатки", 2),)
def test_parse_bsl_module_extracts_condition_function_calls() -> None:
source = Path("tests/golden/condition_call.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert routines[0].calls == (("ПроверитьОстатки", 2),)
def test_parse_bsl_module_extracts_join_tables() -> None:
source = Path("tests/golden/query_join_tables.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert routines[0].queries[0].tables == (
"Документ.ЗаказПокупателя",
"Справочник.Контрагенты",
)
def test_parse_bsl_module_extracts_object_write_targets() -> None:
source = Path("tests/golden/object_write.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert routines[0].writes[0].target == "Справочник.Номенклатура"
assert routines[0].writes[0].write_type == "OBJECT_WRITE"
assert routines[1].writes[0].target == "Документ.CustomerOrder"
assert routines[1].writes[0].write_type == "OBJECT_WRITE"
def test_parse_bsl_module_extracts_recordset_write_targets() -> None:
source = Path("tests/golden/recordset_write.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert routines[0].writes[0].target == "РегистрСведений.Цены"
assert routines[0].writes[0].write_type == "REGISTER_WRITE"
assert routines[1].writes[0].target == "РегистрНакопления.StockBalance"
assert routines[1].writes[0].write_type == "REGISTER_WRITE"
def test_parse_bsl_module_preserves_export_flag() -> None:
source = Path("tests/golden/common_module_export.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert [routine.export for routine in routines] == [True, True]
def test_parse_bsl_module_extracts_calls_and_writes_inside_control_flow() -> None:
source = Path("tests/golden/control_flow_calls.bsl").read_text(encoding="utf-8")
routines = parse_bsl_module(source)
assert routines[0].calls == (("ПроверитьСтроку", 3), ("СообщитьОбОшибке", 9))
assert routines[0].writes[0].target == "ОстаткиТоваров"
def test_index_project_links_english_register_write(tmp_path: Path) -> None:
module = tmp_path / "english_module.bsl"
module.write_text(Path("tests/golden/english_module.bsl").read_text(encoding="utf-8"), encoding="utf-8")
snapshot = index_project(tmp_path, project_id="english")
register = next(node for node in snapshot.nodes if node.kind == NodeKind.REGISTER)
assert register.name == "StockBalance"
assert any(edge.kind == EdgeKind.WRITES and edge.target_lineage == register.lineage_id for edge in snapshot.edges)
def test_index_project_links_object_write_to_metadata_node(tmp_path: Path) -> None:
module = tmp_path / "object_write.bsl"
module.write_text(Path("tests/golden/object_write.bsl").read_text(encoding="utf-8"), encoding="utf-8")
snapshot = index_project(tmp_path, project_id="object-write")
catalog = next(node for node in snapshot.nodes if node.kind == NodeKind.CATALOG)
document = next(node for node in snapshot.nodes if node.kind == NodeKind.DOCUMENT)
assert catalog.qualified_name == "Справочник.Номенклатура"
assert document.qualified_name == "Документ.CustomerOrder"
assert any(edge.kind == EdgeKind.WRITES and edge.target_lineage == catalog.lineage_id for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.WRITES and edge.target_lineage == document.lineage_id for edge in snapshot.edges)
def test_index_project_links_recordset_write_to_register_node(tmp_path: Path) -> None:
module = tmp_path / "recordset_write.bsl"
module.write_text(Path("tests/golden/recordset_write.bsl").read_text(encoding="utf-8"), encoding="utf-8")
snapshot = index_project(tmp_path, project_id="recordset-write")
registers = {node.qualified_name: node for node in snapshot.nodes if node.kind == NodeKind.REGISTER}
assert "РегистрСведений.Цены" in registers
assert "РегистрНакопления.StockBalance" in registers
assert any(
edge.kind == EdgeKind.WRITES and edge.target_lineage == registers["РегистрСведений.Цены"].lineage_id
for edge in snapshot.edges
)
assert any(
edge.kind == EdgeKind.WRITES and edge.target_lineage == registers["РегистрНакопления.StockBalance"].lineage_id
for edge in snapshot.edges
)
def test_index_project_stores_routine_export_attribute(tmp_path: Path) -> None:
module = tmp_path / "common_module_export.bsl"
module.write_text(
Path("tests/golden/common_module_export.bsl").read_text(encoding="utf-8"),
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="exports")
exported = {
node.name
for node in snapshot.nodes
if node.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION} and node.attributes.get("export")
}
assert exported == {"ОтправитьЧек", "BuildPayload"}
def test_parse_bsl_module_from_rust_json_contract() -> None:
payload = {
"procedures": [
{
"name": "Posting",
"is_function": False,
"source_range": {"line_start": 1, "line_end": 4},
},
{
"name": "CheckStock",
"is_function": True,
"source_range": {"line_start": 6, "line_end": 13},
},
],
"calls": [
{
"caller": "Posting",
"callee": "CheckStock",
"source_range": {"line_start": 2, "line_end": 2},
}
],
"queries": [
{
"owner_procedure": "CheckStock",
"query_text": "SELECT\nStock.Item\nFROM\nAccumulationRegister.StockBalance AS Stock",
"tables": ["AccumulationRegister.StockBalance"],
"source_range": {"line_start": 8, "line_end": 12},
}
],
"writes": [
{
"owner_procedure": "Posting",
"target": "StockBalance",
"write_type": "REGISTER_WRITE",
"source_range": {"line_start": 3, "line_end": 3},
}
],
"diagnostics": [],
}
routines = parse_bsl_module_from_rust_json(payload)
assert [routine.name for routine in routines] == ["Posting", "CheckStock"]
assert routines[0].line_end == 4
assert routines[0].calls == (("CheckStock", 2),)
assert routines[0].writes[0].target == "StockBalance"
assert routines[1].queries[0].tables == ("AccumulationRegister.StockBalance",)
def test_index_project_can_use_rust_parser_contract(monkeypatch, tmp_path: Path) -> None:
module = tmp_path / "english_module.bsl"
module.write_text(Path("tests/golden/english_module.bsl").read_text(encoding="utf-8"), encoding="utf-8")
rust_json = r"""
{
"source_path": "english_module.bsl",
"procedures": [
{"name": "Posting", "export": false, "is_function": false, "parameters": [], "source_range": {"line_start": 1, "line_end": 4, "column_start": 1, "column_end": 13}},
{"name": "CheckStock", "export": false, "is_function": true, "parameters": [], "source_range": {"line_start": 6, "line_end": 13, "column_start": 1, "column_end": 12}}
],
"calls": [{"caller": "Posting", "callee": "CheckStock", "source_range": {"line_start": 2, "line_end": 2, "column_start": 1, "column_end": 18}}],
"queries": [{"owner_procedure": "CheckStock", "query_text": "SELECT\nStock.Item\nFROM\nAccumulationRegister.StockBalance AS Stock", "tables": ["AccumulationRegister.StockBalance"], "source_range": {"line_start": 8, "line_end": 12, "column_start": 1, "column_end": 53}}],
"writes": [{"owner_procedure": "Posting", "target": "StockBalance", "write_type": "REGISTER_WRITE", "source_range": {"line_start": 3, "line_end": 3, "column_start": 1, "column_end": 36}}],
"diagnostics": []
}
"""
def fake_run(command, check, capture_output, text, encoding):
assert command[0] == "bsl-parser"
assert Path(command[1]) == module
return subprocess.CompletedProcess(command, 0, stdout=rust_json, stderr="")
monkeypatch.setenv("SFERA_BSL_PARSER", "bsl-parser")
monkeypatch.setattr(semantic_kernel.subprocess, "run", fake_run)
snapshot = index_project(tmp_path, project_id="rust-contract")
assert any(node.kind == NodeKind.FUNCTION and node.name == "CheckStock" for node in snapshot.nodes)
assert any(edge.kind == EdgeKind.CALLS for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.WRITES for edge in snapshot.edges)
def test_index_project_auto_discovers_rust_parser(monkeypatch, tmp_path: Path) -> None:
module = tmp_path / "english_module.bsl"
module.write_text(Path("tests/golden/english_module.bsl").read_text(encoding="utf-8"), encoding="utf-8")
rust_json = r"""
{
"source_path": "english_module.bsl",
"procedures": [
{"name": "Posting", "export": false, "is_function": false, "parameters": [], "source_range": {"line_start": 1, "line_end": 4, "column_start": 1, "column_end": 13}},
{"name": "CheckStock", "export": false, "is_function": true, "parameters": [], "source_range": {"line_start": 6, "line_end": 13, "column_start": 1, "column_end": 12}}
],
"calls": [{"caller": "Posting", "callee": "CheckStock", "source_range": {"line_start": 2, "line_end": 2, "column_start": 1, "column_end": 18}}],
"queries": [{"owner_procedure": "CheckStock", "query_text": "SELECT\nStock.Item\nFROM\nAccumulationRegister.StockBalance AS Stock", "tables": ["AccumulationRegister.StockBalance"], "source_range": {"line_start": 8, "line_end": 12, "column_start": 1, "column_end": 53}}],
"writes": [{"owner_procedure": "Posting", "target": "StockBalance", "write_type": "REGISTER_WRITE", "source_range": {"line_start": 3, "line_end": 3, "column_start": 1, "column_end": 36}}],
"diagnostics": []
}
"""
def fake_run(command, check, capture_output, text, encoding):
assert command[0] == "auto-bsl-parser"
assert Path(command[1]) == module
return subprocess.CompletedProcess(command, 0, stdout=rust_json, stderr="")
monkeypatch.delenv("SFERA_BSL_PARSER", raising=False)
monkeypatch.setattr(semantic_kernel, "_auto_discovered_rust_bsl_parser", lambda source_file: "auto-bsl-parser")
monkeypatch.setattr(semantic_kernel.subprocess, "run", fake_run)
snapshot = index_project(tmp_path, project_id="rust-auto")
assert any(node.kind == NodeKind.FUNCTION and node.name == "CheckStock" for node in snapshot.nodes)
assert any(edge.kind == EdgeKind.CALLS for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.WRITES for edge in snapshot.edges)
@@ -0,0 +1,152 @@
from pathlib import Path
from semantic_kernel import index_project
from sir import EdgeKind, NodeKind, validate_snapshot
def test_index_project_builds_valid_snapshot(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ПроверитьОстатки()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Остатки.Номенклатура
ИЗ
РегистрНакопления.ОстаткиТоваров КАК Остатки";
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="demo")
validate_snapshot(snapshot)
assert any(node.kind == NodeKind.MODULE for node in snapshot.nodes)
assert any(node.kind == NodeKind.PROCEDURE and node.name == "Проведение" for node in snapshot.nodes)
assert any(node.kind == NodeKind.QUERY for node in snapshot.nodes)
assert any(node.kind == NodeKind.REGISTER and node.name == "ОстаткиТоваров" for node in snapshot.nodes)
assert any(edge.kind == EdgeKind.CALLS for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.READS_TABLE for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.WRITES for edge in snapshot.edges)
def test_index_project_builds_integration_endpoint_nodes(tmp_path: Path):
module = tmp_path / "integration.bsl"
module.write_text(
"""
Процедура Отправить()
Адрес = "https://api.example.local/orders";
Объект = Новый COMОбъект("V83.Application");
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="integrations")
assert any(node.kind == NodeKind.INTEGRATION_ENDPOINT for node in snapshot.nodes)
assert any(edge.kind == EdgeKind.USES_INTEGRATION for edge in snapshot.edges)
def test_index_project_extracts_inline_new_query(tmp_path: Path):
module = tmp_path / "query_module.bsl"
module.write_text(
"""
Процедура ПроверитьКонтрагента()
Запрос = Новый Запрос("ВЫБРАТЬ Контрагенты.Ссылка ИЗ Справочник.Контрагенты КАК Контрагенты");
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="inline-query")
query = next(node for node in snapshot.nodes if node.kind == NodeKind.QUERY)
assert "Справочник.Контрагенты" in query.attributes["query_text"]
assert any(
edge.kind == EdgeKind.READS_TABLE
and any(node.lineage_id == edge.target_lineage and node.qualified_name == "Справочник.Контрагенты" for node in snapshot.nodes)
for edge in snapshot.edges
)
def test_index_project_prefers_same_module_routine_for_duplicate_names(tmp_path: Path):
shared = tmp_path / "a_shared.bsl"
shared.write_text(
"""
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
document_module = tmp_path / "z_document.bsl"
document_module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="duplicate-routines")
local_target = next(
node for node in snapshot.nodes if node.qualified_name == "z_document.ПроверитьОстатки"
)
assert any(
edge.kind == EdgeKind.CALLS and edge.target_lineage == local_target.lineage_id
for edge in snapshot.edges
)
def test_index_project_records_malformed_bsl_diagnostics(tmp_path: Path):
module = tmp_path / "broken.bsl"
module.write_text(
"""
Процедура Проведение()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Товары.Ссылка
ИЗ
Справочник.Номенклатура КАК Товары"
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="broken")
assert {diagnostic.code for diagnostic in snapshot.diagnostics} == {
"BSL_UNCLOSED_QUERY",
"BSL_UNCLOSED_ROUTINE",
}
def test_index_project_skips_invalid_xml_and_records_diagnostic(tmp_path: Path):
valid_xml = tmp_path / "valid.xml"
valid_xml.write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
</Configuration>
""",
encoding="utf-8",
)
broken_xml = tmp_path / "broken.mdo"
broken_xml.write_text("<mdclass:Catalog xmlns:mdclass=", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="broken-xml")
assert any(node.qualified_name == "Справочник.Контрагенты" for node in snapshot.nodes)
assert any(diagnostic.code == "XML_PARSE_ERROR" for diagnostic in snapshot.diagnostics)
@@ -0,0 +1,21 @@
from pathlib import Path
from review_engine import review_snapshot
from semantic_kernel import index_project
def test_index_project_records_unresolved_calls(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
НеизвестнаяПроцедура();
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="demo")
assert snapshot.unresolved_references[0].target_name == "НеизвестнаяПроцедура"
assert review_snapshot(snapshot)[0].title == "Unresolved call"
@@ -0,0 +1,383 @@
from pathlib import Path
from semantic_kernel import index_project
from sir import EdgeKind, NodeKind
from ui_semantics import form_semantics
def test_index_project_extracts_xml_ui_semantics(tmp_path: Path):
xml = tmp_path / "form.xml"
xml.write_text(
"""
<Form name="ФормаДокумента" qualifiedName="Документ.Заказ.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.Заказ.ФормаДокумента.Провести" />
</Form>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="demo")
assert any(node.kind == NodeKind.FORM for node in snapshot.nodes)
assert any(edge.kind == EdgeKind.HAS_COMMAND for edge in snapshot.edges)
assert form_semantics(snapshot)[0].commands[0].name == "Провести"
def test_index_project_extracts_1c_metadata_objects(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Catalog name="Номенклатура" qualifiedName="Справочник.Номенклатура">
<Attribute name="Артикул" qualifiedName="Справочник.Номенклатура.Артикул" />
</Catalog>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" />
</Form>
</Document>
<Role name="Менеджер" qualifiedName="Роль.Менеджер" />
<MetadataObject type="ScheduledJob">
<Name>ОбменСКассовымУзлом</Name>
<QualifiedName>РегламентноеЗадание.ОбменСКассовымУзлом</QualifiedName>
</MetadataObject>
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="metadata")
assert any(node.kind == NodeKind.CATALOG and node.name == "Номенклатура" for node in snapshot.nodes)
assert any(node.kind == NodeKind.DOCUMENT and node.name == "ЗаказПокупателя" for node in snapshot.nodes)
assert any(node.kind == NodeKind.TABULAR_SECTION and node.name == "Товары" for node in snapshot.nodes)
assert any(node.kind == NodeKind.ROLE and node.name == "Менеджер" for node in snapshot.nodes)
assert any(node.kind == NodeKind.SCHEDULED_JOB for node in snapshot.nodes)
assert any(edge.kind == EdgeKind.HAS_ATTRIBUTE for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.HAS_TABULAR_SECTION for edge in snapshot.edges)
tabular_section = next(node for node in snapshot.nodes if node.kind == NodeKind.TABULAR_SECTION)
column = next(node for node in snapshot.nodes if node.kind == NodeKind.ATTRIBUTE and node.name == "Номенклатура")
assert any(
edge.kind == EdgeKind.HAS_ATTRIBUTE
and edge.source_lineage == tabular_section.lineage_id
and edge.target_lineage == column.lineage_id
for edge in snapshot.edges
)
assert any(edge.kind == EdgeKind.HAS_FORM for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.HAS_COMMAND for edge in snapshot.edges)
def test_index_project_remaps_edges_from_duplicate_metadata_nodes(tmp_path: Path):
first = tmp_path / "first.xml"
first.write_text(
"""
<Configuration>
<MetadataObject type="ChartOfCharacteristicTypes">
<Name>СвойстваОбъектов</Name>
<QualifiedName>ПланВидовХарактеристик.СвойстваОбъектов</QualifiedName>
</MetadataObject>
</Configuration>
""",
encoding="utf-8",
)
second = tmp_path / "second.xml"
second.write_text(
"""
<Configuration>
<MetadataObject type="ChartOfCharacteristicTypes">
<Name>СвойстваОбъектов</Name>
<QualifiedName>ПланВидовХарактеристик.СвойстваОбъектов</QualifiedName>
</MetadataObject>
<Attribute name="ТипЗначения" qualifiedName="ПланВидовХарактеристик.СвойстваОбъектов.ТипЗначения" />
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="metadata-duplicates")
chart = next(node for node in snapshot.nodes if node.kind == NodeKind.CHART_OF_CHARACTERISTIC_TYPES)
attribute = next(node for node in snapshot.nodes if node.kind == NodeKind.ATTRIBUTE)
assert any(
edge.kind == EdgeKind.HAS_ATTRIBUTE
and edge.source_lineage == chart.lineage_id
and edge.target_lineage == attribute.lineage_id
for edge in snapshot.edges
)
def test_index_project_links_document_metadata_to_object_module(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text(
"""
Процедура Проведение()
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="metadata-links")
document = next(node for node in snapshot.nodes if node.kind == NodeKind.DOCUMENT)
module_node = next(node for node in snapshot.nodes if node.kind == NodeKind.MODULE)
link = next(
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.CONTAINS
and edge.source_lineage == document.lineage_id
and edge.target_lineage == module_node.lineage_id
)
assert link.attributes["module_role"] == "OBJECT_MODULE"
def test_index_project_links_common_module_metadata_to_bsl_module(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<CommonModule name="ИнтеграцияСКассой" qualifiedName="ОбщийМодуль.ИнтеграцияСКассой" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "CommonModules" / "ИнтеграцияСКассой" / "Module.bsl"
module.parent.mkdir(parents=True)
module.write_text(
"""
Процедура ОтправитьЧек()
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="common-module-links")
common_module = next(node for node in snapshot.nodes if node.kind == NodeKind.COMMON_MODULE)
bsl_module = next(node for node in snapshot.nodes if node.kind == NodeKind.MODULE)
assert any(
edge.kind == EdgeKind.CONTAINS
and edge.source_lineage == common_module.lineage_id
and edge.target_lineage == bsl_module.lineage_id
for edge in snapshot.edges
)
def test_index_project_reads_edt_mdo_metadata(tmp_path: Path):
catalog_dir = tmp_path / "src" / "Catalogs" / "Товары"
catalog_dir.mkdir(parents=True)
(catalog_dir / "Товары.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Товары</name>
<attributes>
<name>Артикул</name>
</attributes>
<forms>
<name>ФормаЭлемента</name>
</forms>
</mdclass:Catalog>
""",
encoding="utf-8",
)
report_dir = tmp_path / "src" / "Reports" / "АнализПродаж"
report_dir.mkdir(parents=True)
(report_dir / "АнализПродаж.mdo").write_text(
"""
<mdclass:Report xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>АнализПродаж</name>
</mdclass:Report>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="edt-mdo")
catalog = next(node for node in snapshot.nodes if node.kind == NodeKind.CATALOG)
attribute = next(node for node in snapshot.nodes if node.kind == NodeKind.ATTRIBUTE and node.name == "Артикул")
form = next(node for node in snapshot.nodes if node.kind == NodeKind.FORM)
report = next(node for node in snapshot.nodes if node.kind == NodeKind.REPORT)
assert catalog.qualified_name == "Справочник.Товары"
assert attribute.qualified_name == "Справочник.Товары.Артикул"
assert form.qualified_name == "Справочник.Товары.ФормаЭлемента"
assert report.qualified_name == "Отчет.АнализПродаж"
assert any(edge.kind == EdgeKind.HAS_ATTRIBUTE and edge.source_lineage == catalog.lineage_id for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.HAS_FORM and edge.source_lineage == catalog.lineage_id for edge in snapshot.edges)
def test_index_project_preserves_register_dimension_and_resource_roles(tmp_path: Path):
register_dir = tmp_path / "src" / "AccumulationRegisters" / "ОстаткиТоваров"
register_dir.mkdir(parents=True)
(register_dir / "ОстаткиТоваров.mdo").write_text(
"""
<mdclass:AccumulationRegister xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>ОстаткиТоваров</name>
<dimensions>
<name>Номенклатура</name>
</dimensions>
<resources>
<name>Количество</name>
</resources>
</mdclass:AccumulationRegister>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="register-fields")
register = next(node for node in snapshot.nodes if node.kind == NodeKind.REGISTER)
dimension = next(node for node in snapshot.nodes if node.kind == NodeKind.ATTRIBUTE and node.name == "Номенклатура")
resource = next(node for node in snapshot.nodes if node.kind == NodeKind.ATTRIBUTE and node.name == "Количество")
assert dimension.attributes["attribute_role"] == "DIMENSION"
assert resource.attributes["attribute_role"] == "RESOURCE"
assert any(
edge.kind == EdgeKind.HAS_ATTRIBUTE
and edge.source_lineage == register.lineage_id
and edge.target_lineage == dimension.lineage_id
for edge in snapshot.edges
)
assert any(
edge.kind == EdgeKind.HAS_ATTRIBUTE
and edge.source_lineage == register.lineage_id
and edge.target_lineage == resource.lineage_id
for edge in snapshot.edges
)
def test_index_project_links_role_rights_to_metadata_objects(tmp_path: Path):
xml = tmp_path / "roles.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
<Right object="Документ.ЗаказПокупателя" read="true" write="true" post="true" />
</Role>
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="role-rights")
role = next(node for node in snapshot.nodes if node.kind == NodeKind.ROLE)
document = next(node for node in snapshot.nodes if node.kind == NodeKind.DOCUMENT)
right = next(edge for edge in snapshot.edges if edge.kind == EdgeKind.GRANTS_ACCESS)
assert right.source_lineage == role.lineage_id
assert right.target_lineage == document.lineage_id
assert right.attributes["post"] == "true"
def test_index_project_links_child_element_command_action_and_role_right(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document>
<Name>ЗаказПокупателя</Name>
<QualifiedName>Документ.ЗаказПокупателя</QualifiedName>
<Form>
<Name>ФормаДокумента</Name>
<Command>
<Name>Провести</Name>
<Action>ПровестиКоманда</Action>
</Command>
</Form>
</Document>
<Role>
<Name>Менеджер</Name>
<Right read="true">
<Object>Документ.ЗаказПокупателя</Object>
</Right>
</Role>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "form_module.bsl"
module.write_text("Процедура ПровестиКоманда()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="child-element-metadata")
document = next(node for node in snapshot.nodes if node.kind == NodeKind.DOCUMENT)
role = next(node for node in snapshot.nodes if node.kind == NodeKind.ROLE)
command = next(node for node in snapshot.nodes if node.kind == NodeKind.COMMAND)
assert any(
edge.kind == EdgeKind.GRANTS_ACCESS
and edge.source_lineage == role.lineage_id
and edge.target_lineage == document.lineage_id
for edge in snapshot.edges
)
assert any(
edge.kind == EdgeKind.HANDLES
and edge.source_lineage == command.lineage_id
and edge.attributes["handler_name"] == "ПровестиКоманда"
for edge in snapshot.edges
)
def test_index_project_links_form_command_to_handler(tmp_path: Path):
xml = tmp_path / "form.xml"
xml.write_text(
"""
<Form name="ФормаДокумента" qualifiedName="Документ.Заказ.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.Заказ.ФормаДокумента.Провести" action="ПровестиКоманда" />
</Form>
""",
encoding="utf-8",
)
module = tmp_path / "form_module.bsl"
module.write_text("Процедура ПровестиКоманда()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(tmp_path, project_id="ui-handlers")
assert any(edge.kind == EdgeKind.HANDLES for edge in snapshot.edges)
def test_index_project_links_form_events_to_handlers(tmp_path: Path):
xml = tmp_path / "form.xml"
xml.write_text(
"""
<Form
name="ФормаДокумента"
qualifiedName="Документ.Заказ.ФормаДокумента"
onCreate="ПриСозданииНаСервере"
beforeWrite="ПередЗаписью"
/>
""",
encoding="utf-8",
)
module = tmp_path / "form_module.bsl"
module.write_text(
"""
Процедура ПриСозданииНаСервере()
КонецПроцедуры
Процедура ПередЗаписью()
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="ui-form-events")
form = next(node for node in snapshot.nodes if node.kind == NodeKind.FORM)
handlers = {
edge.attributes["handler_name"]: edge
for edge in snapshot.edges
if edge.kind == EdgeKind.HANDLES and edge.source_lineage == form.lineage_id
}
assert set(handlers) == {"ПриСозданииНаСервере", "ПередЗаписью"}
assert {edge.attributes["link_type"] for edge in handlers.values()} == {"FORM_EVENT"}
+9
View File
@@ -0,0 +1,9 @@
# sfera-semantic-search
Deterministic semantic search over SIR snapshots.
Search covers:
- node name, qualified name, kind, and source path;
- metadata attributes;
- indexed module source text where present.
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-semantic-search"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,85 @@
from __future__ import annotations
from collections.abc import Iterable
from pydantic import BaseModel
from sir import SemanticNode, SirSnapshot
class SearchResult(BaseModel):
node: SemanticNode
score: float
matched_fields: list[str]
def search_snapshot(
snapshot: SirSnapshot,
query: str,
*,
kinds: set[str] | None = None,
limit: int = 20,
) -> list[SearchResult]:
normalized_query = query.casefold().strip()
if not normalized_query:
return []
results: list[SearchResult] = []
for node in snapshot.nodes:
if kinds is not None and node.kind.value not in kinds:
continue
score, fields = _score_node(node, normalized_query)
if score > 0:
results.append(SearchResult(node=node, score=score, matched_fields=fields))
results.sort(key=lambda result: (-result.score, result.node.qualified_name))
return results[:limit]
def _score_node(node: SemanticNode, query: str) -> tuple[float, list[str]]:
fields = {
"name": node.name,
"qualified_name": node.qualified_name,
"kind": node.kind.value,
"source_path": node.source_ref.source_path,
}
score = 0.0
matched: list[str] = []
for field, value in fields.items():
field_score = _score_text(value, query)
if field_score:
score += field_score
matched.append(field)
for field, value in _attribute_search_fields(node.attributes):
field_score = _score_text(value, query)
if field_score:
score += max(field_score - 1.0, 1.0)
matched.append(field)
return score, matched
def _score_text(value: object, query: str) -> float:
normalized = str(value).casefold()
if normalized == query:
return 10.0
if normalized.startswith(query):
return 5.0
if query in normalized:
return 2.0
return 0.0
def _attribute_search_fields(attributes: dict) -> Iterable[tuple[str, object]]:
for key, value in sorted(attributes.items()):
field = f"attributes.{key}"
if isinstance(value, dict):
for nested_key, nested_value in _attribute_search_fields(value):
yield f"{field}.{nested_key.removeprefix('attributes.')}", nested_value
elif isinstance(value, list):
for index, item in enumerate(value):
yield f"{field}[{index}]", item
else:
yield field, value
__all__ = ["SearchResult", "search_snapshot"]
@@ -0,0 +1,59 @@
from pathlib import Path
from semantic_kernel import index_project
from semantic_search import search_snapshot
def test_search_snapshot_finds_routine_by_partial_name(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="demo")
results = search_snapshot(snapshot, "Пров", kinds={"PROCEDURE"})
assert results
assert results[0].node.name == "Проведение"
def test_search_snapshot_finds_node_by_source_text(tmp_path: Path):
module = tmp_path / "integration.bsl"
module.write_text(
"""
Процедура Отправить()
Адрес = "https://api.example.local/orders";
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="search-source-text")
results = search_snapshot(snapshot, "api.example.local")
assert results
module_result = next(result for result in results if result.node.name == "integration")
assert "attributes.source_text" in module_result.matched_fields
def test_search_snapshot_finds_node_by_metadata_attribute(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" synonym="Customer Order" />
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="search-attributes")
results = search_snapshot(snapshot, "Customer")
assert results
assert results[0].node.qualified_name == "Документ.ЗаказПокупателя"
assert "attributes.synonym" in results[0].matched_fields
+11
View File
@@ -0,0 +1,11 @@
# sfera-semantic-versioning
Object-level semantic version store.
Provides:
- immutable `SemanticObjectVersion` records per SIR lineage;
- snapshot-to-version materialization;
- task/session metadata on object versions;
- deterministic payload hashes and duplicate suppression;
- JSON-path semantic diffs between two versions.
@@ -0,0 +1,11 @@
[project]
name = "sfera-semantic-versioning"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,172 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from pydantic import BaseModel, Field
from sir import SemanticNode, SirSnapshot, stable_hash
class SemanticObjectVersion(BaseModel):
version_id: str
lineage_id: str
semantic_id: str
object_hash: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
task_id: str | None = None
session_id: str | None = None
parent_version_id: str | None = None
payload: dict = Field(default_factory=dict)
class SemanticObjectDiffEntry(BaseModel):
path: str
kind: str
before: object | None = None
after: object | None = None
class SemanticObjectDiff(BaseModel):
from_version_id: str
to_version_id: str
lineage_id: str
changed: bool
entries: list[SemanticObjectDiffEntry] = Field(default_factory=list)
class SemanticObjectChange(BaseModel):
change_kind: str
diff: SemanticObjectDiff
class InMemoryObjectVersionStore:
def __init__(self) -> None:
self._versions_by_lineage: dict[str, list[SemanticObjectVersion]] = {}
def append_node_version(
self,
node: SemanticNode,
*,
task_id: str | None = None,
session_id: str | None = None,
) -> SemanticObjectVersion:
previous = self.latest(node.lineage_id)
payload = node.model_dump(mode="json")
object_hash = stable_hash(json.dumps(payload, ensure_ascii=False, sort_keys=True))
version = SemanticObjectVersion(
version_id=f"version.{node.lineage_id}.{object_hash}",
lineage_id=node.lineage_id,
semantic_id=node.semantic_id,
object_hash=object_hash,
task_id=task_id,
session_id=session_id,
parent_version_id=previous.version_id if previous else None,
payload=payload,
)
versions = self._versions_by_lineage.setdefault(node.lineage_id, [])
if not versions or versions[-1].object_hash != object_hash:
versions.append(version)
return versions[-1]
def append_snapshot_versions(
self,
snapshot: SirSnapshot,
*,
task_id: str | None = None,
session_id: str | None = None,
) -> list[SemanticObjectVersion]:
return [
self.append_node_version(node, task_id=task_id, session_id=session_id)
for node in snapshot.nodes
]
def upsert_version(self, version: SemanticObjectVersion) -> SemanticObjectVersion:
versions = self._versions_by_lineage.setdefault(version.lineage_id, [])
if all(existing.version_id != version.version_id for existing in versions):
versions.append(version)
versions.sort(key=lambda item: item.created_at)
return version
def latest(self, lineage_id: str) -> SemanticObjectVersion | None:
versions = self._versions_by_lineage.get(lineage_id, [])
return versions[-1] if versions else None
def find_version(self, version_id: str) -> SemanticObjectVersion | None:
return next((version for version in self.all_versions() if version.version_id == version_id), None)
def history(self, lineage_id: str) -> list[SemanticObjectVersion]:
return list(self._versions_by_lineage.get(lineage_id, []))
def all_versions(self) -> list[SemanticObjectVersion]:
return [
version
for versions in self._versions_by_lineage.values()
for version in versions
]
def diff_versions(
before: SemanticObjectVersion,
after: SemanticObjectVersion,
) -> SemanticObjectDiff:
if before.lineage_id != after.lineage_id:
raise ValueError("Cannot diff versions from different lineage_id values")
entries = _diff_payload("", before.payload, after.payload)
return SemanticObjectDiff(
from_version_id=before.version_id,
to_version_id=after.version_id,
lineage_id=before.lineage_id,
changed=bool(entries),
entries=entries,
)
def classify_version_change(
before: SemanticObjectVersion,
after: SemanticObjectVersion,
) -> SemanticObjectChange:
diff = diff_versions(before, after)
if not diff.changed:
change_kind = "UNCHANGED"
elif _object_parent(before.payload.get("qualified_name")) != _object_parent(after.payload.get("qualified_name")):
change_kind = "MOVE"
elif before.payload.get("name") != after.payload.get("name"):
change_kind = "RENAME"
else:
change_kind = "UPDATE"
return SemanticObjectChange(change_kind=change_kind, diff=diff)
def _object_parent(qualified_name: object) -> str:
if not isinstance(qualified_name, str) or "." not in qualified_name:
return ""
return qualified_name.rsplit(".", 1)[0]
def _diff_payload(path: str, before: object, after: object) -> list[SemanticObjectDiffEntry]:
if isinstance(before, dict) and isinstance(after, dict):
entries: list[SemanticObjectDiffEntry] = []
for key in sorted(set(before) | set(after)):
child_path = f"{path}.{key}" if path else str(key)
if key not in before:
entries.append(SemanticObjectDiffEntry(path=child_path, kind="ADD", after=after[key]))
elif key not in after:
entries.append(SemanticObjectDiffEntry(path=child_path, kind="REMOVE", before=before[key]))
else:
entries.extend(_diff_payload(child_path, before[key], after[key]))
return entries
if before == after:
return []
return [SemanticObjectDiffEntry(path=path or "$", kind="CHANGE", before=before, after=after)]
__all__ = [
"InMemoryObjectVersionStore",
"SemanticObjectDiff",
"SemanticObjectDiffEntry",
"SemanticObjectChange",
"SemanticObjectVersion",
"classify_version_change",
"diff_versions",
]
@@ -0,0 +1,123 @@
from semantic_versioning import InMemoryObjectVersionStore, SemanticObjectVersion, classify_version_change, diff_versions
from sir import NodeKind, SemanticNode, SirSnapshot, SourceRef
def test_object_version_store_deduplicates_same_payload():
node = SemanticNode(
semantic_id="procedure.demo",
lineage_id="lineage.procedure.demo",
kind=NodeKind.PROCEDURE,
name="Demo",
qualified_name="Module.Demo",
source_ref=SourceRef(source_path="module.bsl"),
)
store = InMemoryObjectVersionStore()
first = store.append_node_version(node)
second = store.append_node_version(node)
assert first.version_id == second.version_id
assert len(store.history(node.lineage_id)) == 1
def test_object_version_store_versions_snapshot_nodes():
node = SemanticNode(
semantic_id="procedure.demo",
lineage_id="lineage.procedure.demo",
kind=NodeKind.PROCEDURE,
name="Demo",
qualified_name="Module.Demo",
source_ref=SourceRef(source_path="module.bsl"),
)
snapshot = SirSnapshot(snapshot_id="snapshot.demo", project_id="demo", nodes=[node])
store = InMemoryObjectVersionStore()
versions = store.append_snapshot_versions(snapshot, task_id="task.1")
assert versions[0].task_id == "task.1"
assert store.all_versions()[0].lineage_id == node.lineage_id
def test_object_version_diff_reports_payload_changes():
before = SemanticObjectVersion(
version_id="version.1",
lineage_id="lineage.procedure.demo",
semantic_id="procedure.demo",
object_hash="hash.1",
payload={"name": "Demo", "attributes": {"export": False, "source": "old"}},
)
after = SemanticObjectVersion(
version_id="version.2",
lineage_id="lineage.procedure.demo",
semantic_id="procedure.demo",
object_hash="hash.2",
payload={"name": "Demo", "attributes": {"export": True}, "kind": "PROCEDURE"},
)
diff = diff_versions(before, after)
assert diff.changed is True
assert [(entry.path, entry.kind) for entry in diff.entries] == [
("attributes.export", "CHANGE"),
("attributes.source", "REMOVE"),
("kind", "ADD"),
]
def test_object_version_store_finds_version_by_id():
node = SemanticNode(
semantic_id="procedure.demo",
lineage_id="lineage.procedure.demo",
kind=NodeKind.PROCEDURE,
name="Demo",
qualified_name="Module.Demo",
source_ref=SourceRef(source_path="module.bsl"),
)
store = InMemoryObjectVersionStore()
version = store.append_node_version(node)
assert store.find_version(version.version_id) == version
def test_classify_version_change_detects_object_rename():
before = SemanticObjectVersion(
version_id="version.1",
lineage_id="lineage.document.order",
semantic_id="document.order",
object_hash="hash.1",
payload={"name": "ЗаказПокупателя", "qualified_name": "Документ.ЗаказПокупателя"},
)
after = SemanticObjectVersion(
version_id="version.2",
lineage_id="lineage.document.order",
semantic_id="document.order",
object_hash="hash.2",
payload={"name": "ЗаказКлиента", "qualified_name": "Документ.ЗаказКлиента"},
)
change = classify_version_change(before, after)
assert change.change_kind == "RENAME"
assert {entry.path for entry in change.diff.entries} == {"name", "qualified_name"}
def test_classify_version_change_detects_object_move():
before = SemanticObjectVersion(
version_id="version.1",
lineage_id="lineage.form.order",
semantic_id="form.order",
object_hash="hash.1",
payload={"name": "ФормаДокумента", "qualified_name": "Документ.ЗаказПокупателя.ФормаДокумента"},
)
after = SemanticObjectVersion(
version_id="version.2",
lineage_id="lineage.form.order",
semantic_id="form.order",
object_hash="hash.2",
payload={"name": "ФормаДокумента", "qualified_name": "Документ.ЗаказКлиента.ФормаДокумента"},
)
change = classify_version_change(before, after)
assert change.change_kind == "MOVE"
assert [entry.path for entry in change.diff.entries] == ["qualified_name"]
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-sir"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"orjson>=3.10",
]
[tool.uv]
package = true
+35
View File
@@ -0,0 +1,35 @@
from sir.delta import SirDelta
from sir.diagnostics import Diagnostic, DiagnosticSeverity
from sir.edges import SemanticEdge
from sir.enums import EdgeKind, NodeKind
from sir.hashing import compute_snapshot_hash
from sir.lineage import make_lineage_id, make_semantic_id, stable_hash
from sir.nodes import SemanticNode
from sir.references import ReferenceKind, UnresolvedReference
from sir.serialization import snapshot_from_json, snapshot_to_json
from sir.snapshot import SirSnapshot, SnapshotMetadata
from sir.source_ref import SourceRef
from sir.validation import SnapshotValidationError, validate_snapshot
__all__ = [
"Diagnostic",
"DiagnosticSeverity",
"EdgeKind",
"NodeKind",
"ReferenceKind",
"SemanticEdge",
"SemanticNode",
"SirDelta",
"SirSnapshot",
"SnapshotMetadata",
"SnapshotValidationError",
"SourceRef",
"UnresolvedReference",
"compute_snapshot_hash",
"make_lineage_id",
"make_semantic_id",
"snapshot_from_json",
"snapshot_to_json",
"stable_hash",
"validate_snapshot",
]
+15
View File
@@ -0,0 +1,15 @@
from pydantic import BaseModel, Field
from sir.edges import SemanticEdge
from sir.nodes import SemanticNode
class SirDelta(BaseModel):
delta_id: str
snapshot_from: str
snapshot_to: str
added_nodes: list[SemanticNode] = Field(default_factory=list)
updated_nodes: list[SemanticNode] = Field(default_factory=list)
removed_nodes: list[str] = Field(default_factory=list)
added_edges: list[SemanticEdge] = Field(default_factory=list)
removed_edges: list[str] = Field(default_factory=list)
+21
View File
@@ -0,0 +1,21 @@
from enum import Enum
from pydantic import BaseModel, Field
from sir.source_ref import SourceRef
class DiagnosticSeverity(str, Enum):
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
class Diagnostic(BaseModel):
diagnostic_id: str
code: str
severity: DiagnosticSeverity
message: str
source_ref: SourceRef | None = None
related_lineages: list[str] = Field(default_factory=list)
attributes: dict = Field(default_factory=dict)
+13
View File
@@ -0,0 +1,13 @@
from pydantic import BaseModel, Field
from sir.enums import EdgeKind
from sir.source_ref import SourceRef
class SemanticEdge(BaseModel):
edge_id: str
kind: EdgeKind
source_lineage: str
target_lineage: str
source_ref: SourceRef | None = None
attributes: dict = Field(default_factory=dict)
+59
View File
@@ -0,0 +1,59 @@
from enum import Enum
class NodeKind(str, Enum):
PROJECT = "PROJECT"
MODULE = "MODULE"
PROCEDURE = "PROCEDURE"
FUNCTION = "FUNCTION"
QUERY = "QUERY"
TABLE = "TABLE"
REGISTER = "REGISTER"
CATALOG = "CATALOG"
DOCUMENT = "DOCUMENT"
CONSTANT = "CONSTANT"
DOCUMENT_JOURNAL = "DOCUMENT_JOURNAL"
ENUM = "ENUM"
REPORT = "REPORT"
DATA_PROCESSOR = "DATA_PROCESSOR"
CHART_OF_CHARACTERISTIC_TYPES = "CHART_OF_CHARACTERISTIC_TYPES"
CHART_OF_ACCOUNTS = "CHART_OF_ACCOUNTS"
CHART_OF_CALCULATION_TYPES = "CHART_OF_CALCULATION_TYPES"
COMMON_MODULE = "COMMON_MODULE"
EXCHANGE_PLAN = "EXCHANGE_PLAN"
EXTERNAL_DATA_SOURCE = "EXTERNAL_DATA_SOURCE"
SCHEDULED_JOB = "SCHEDULED_JOB"
BUSINESS_PROCESS = "BUSINESS_PROCESS"
TASK = "TASK"
SUBSYSTEM = "SUBSYSTEM"
HTTP_SERVICE = "HTTP_SERVICE"
XDTO_PACKAGE = "XDTO_PACKAGE"
EXTENSION = "EXTENSION"
LAYOUT = "LAYOUT"
MOVEMENT = "MOVEMENT"
INTEGRATION_ENDPOINT = "INTEGRATION_ENDPOINT"
FORM = "FORM"
COMMAND = "COMMAND"
ROLE = "ROLE"
ATTRIBUTE = "ATTRIBUTE"
TABULAR_SECTION = "TABULAR_SECTION"
FORM_ELEMENT = "FORM_ELEMENT"
class EdgeKind(str, Enum):
CONTAINS = "CONTAINS"
DECLARES = "DECLARES"
CALLS = "CALLS"
OWNS_QUERY = "OWNS_QUERY"
READS_TABLE = "READS_TABLE"
WRITES = "WRITES"
HAS_FORM = "HAS_FORM"
HAS_COMMAND = "HAS_COMMAND"
HAS_ROLE = "HAS_ROLE"
HAS_ATTRIBUTE = "HAS_ATTRIBUTE"
HAS_TABULAR_SECTION = "HAS_TABULAR_SECTION"
HAS_ELEMENT = "HAS_ELEMENT"
GRANTS_ACCESS = "GRANTS_ACCESS"
RUNS = "RUNS"
USES_INTEGRATION = "USES_INTEGRATION"
HANDLES = "HANDLES"
+13
View File
@@ -0,0 +1,13 @@
import hashlib
import orjson
from sir.snapshot import SirSnapshot
def compute_snapshot_hash(snapshot: SirSnapshot) -> str:
payload = snapshot.model_dump(mode="json")
payload["snapshot_hash"] = None
payload["created_at"] = None
raw = orjson.dumps(payload, option=orjson.OPT_SORT_KEYS)
return hashlib.sha256(raw).hexdigest()
+13
View File
@@ -0,0 +1,13 @@
import hashlib
def stable_hash(value: str) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
def make_lineage_id(kind: str, stable_key: str) -> str:
return f"lineage.{kind.lower()}.{stable_hash(stable_key)}"
def make_semantic_id(kind: str, qualified_name: str) -> str:
return f"{kind.lower()}.{stable_hash(qualified_name)}"
+14
View File
@@ -0,0 +1,14 @@
from pydantic import BaseModel, Field
from sir.enums import NodeKind
from sir.source_ref import SourceRef
class SemanticNode(BaseModel):
semantic_id: str
lineage_id: str
kind: NodeKind
name: str
qualified_name: str
source_ref: SourceRef
attributes: dict = Field(default_factory=dict)
+22
View File
@@ -0,0 +1,22 @@
from enum import Enum
from pydantic import BaseModel, Field
from sir.source_ref import SourceRef
class ReferenceKind(str, Enum):
CALL = "CALL"
TABLE = "TABLE"
ATTRIBUTE = "ATTRIBUTE"
FORM_ELEMENT = "FORM_ELEMENT"
COMMAND = "COMMAND"
class UnresolvedReference(BaseModel):
reference_id: str
kind: ReferenceKind
source_lineage: str
target_name: str
source_ref: SourceRef
attributes: dict = Field(default_factory=dict)
+11
View File
@@ -0,0 +1,11 @@
import orjson
from sir.snapshot import SirSnapshot
def snapshot_to_json(snapshot: SirSnapshot) -> bytes:
return orjson.dumps(snapshot.model_dump(mode="json"), option=orjson.OPT_INDENT_2)
def snapshot_from_json(data: bytes) -> SirSnapshot:
return SirSnapshot.model_validate_json(data)
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from sir.diagnostics import Diagnostic
from sir.edges import SemanticEdge
from sir.nodes import SemanticNode
from sir.references import UnresolvedReference
class SnapshotMetadata(BaseModel):
platform_version: str | None = None
compatibility_mode: str | None = None
source_kind: str | None = None
source_root: str | None = None
created_by: str | None = None
task_id: str | None = None
session_id: str | None = None
class SirSnapshot(BaseModel):
sir_schema_version: str = "0.1"
snapshot_id: str
project_id: str
revision: str | None = None
snapshot_hash: str | None = None
metadata: SnapshotMetadata = Field(default_factory=SnapshotMetadata)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
nodes: list[SemanticNode] = Field(default_factory=list)
edges: list[SemanticEdge] = Field(default_factory=list)
diagnostics: list[Diagnostic] = Field(default_factory=list)
unresolved_references: list[UnresolvedReference] = Field(default_factory=list)
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class SourceRef(BaseModel):
source_path: str
line_start: int | None = None
line_end: int | None = None
column_start: int | None = None
column_end: int | None = None
source_hash: str | None = None
+30
View File
@@ -0,0 +1,30 @@
from sir.snapshot import SirSnapshot
class SnapshotValidationError(Exception):
"""Raised when a SIR snapshot violates graph consistency rules."""
def validate_snapshot(snapshot: SirSnapshot) -> None:
semantic_ids = [node.semantic_id for node in snapshot.nodes]
lineage_ids = [node.lineage_id for node in snapshot.nodes]
if len(semantic_ids) != len(set(semantic_ids)):
raise SnapshotValidationError("Duplicate semantic_id detected")
if len(lineage_ids) != len(set(lineage_ids)):
raise SnapshotValidationError("Duplicate lineage_id detected")
known_lineages = set(lineage_ids)
for edge in snapshot.edges:
if edge.source_lineage not in known_lineages:
raise SnapshotValidationError(f"Dangling edge source: {edge.source_lineage}")
if edge.target_lineage not in known_lineages:
raise SnapshotValidationError(f"Dangling edge target: {edge.target_lineage}")
for reference in snapshot.unresolved_references:
if reference.source_lineage not in known_lineages:
raise SnapshotValidationError(
f"Unresolved reference with unknown source: {reference.source_lineage}"
)
+8
View File
@@ -0,0 +1,8 @@
from sir import SirDelta
def test_delta_defaults():
delta = SirDelta(delta_id="delta.1", snapshot_from="a", snapshot_to="b")
assert delta.added_nodes == []
assert delta.updated_nodes == []
assert delta.removed_nodes == []
+15
View File
@@ -0,0 +1,15 @@
from sir import NodeKind, SemanticNode, SirSnapshot, SourceRef, compute_snapshot_hash
def test_snapshot_hash_deterministic_ignores_created_at():
node = SemanticNode(
semantic_id="module.demo",
lineage_id="lineage.module.demo",
kind=NodeKind.MODULE,
name="Module",
qualified_name="Module",
source_ref=SourceRef(source_path="module.bsl"),
)
a = SirSnapshot(snapshot_id="snapshot.demo", project_id="demo", nodes=[node])
b = SirSnapshot(snapshot_id="snapshot.demo", project_id="demo", nodes=[node])
assert compute_snapshot_hash(a) == compute_snapshot_hash(b)
+17
View File
@@ -0,0 +1,17 @@
from sir import NodeKind, SemanticNode, SirSnapshot, SourceRef, snapshot_from_json, snapshot_to_json
def test_snapshot_serialization_roundtrip():
node = SemanticNode(
semantic_id="module.demo",
lineage_id="lineage.module.demo",
kind=NodeKind.MODULE,
name="Module",
qualified_name="Module",
source_ref=SourceRef(source_path="module.bsl"),
)
snapshot = SirSnapshot(snapshot_id="snapshot.demo", project_id="demo", nodes=[node])
data = snapshot_to_json(snapshot)
restored = snapshot_from_json(data)
assert restored.snapshot_id == snapshot.snapshot_id
assert restored.nodes[0].lineage_id == node.lineage_id
@@ -0,0 +1,51 @@
import pytest
from sir import EdgeKind, NodeKind, SemanticEdge, SemanticNode, SirSnapshot, SourceRef
from sir import SnapshotValidationError, validate_snapshot
def make_nodes():
module = SemanticNode(
semantic_id="module.demo",
lineage_id="lineage.module.demo",
kind=NodeKind.MODULE,
name="Module",
qualified_name="Module",
source_ref=SourceRef(source_path="module.bsl"),
)
procedure = SemanticNode(
semantic_id="procedure.posting",
lineage_id="lineage.procedure.posting",
kind=NodeKind.PROCEDURE,
name="Проведение",
qualified_name="Module.Проведение",
source_ref=SourceRef(source_path="module.bsl"),
)
return module, procedure
def test_snapshot_validation_ok():
module, procedure = make_nodes()
edge = SemanticEdge(
edge_id="edge.declares.1",
kind=EdgeKind.DECLARES,
source_lineage=module.lineage_id,
target_lineage=procedure.lineage_id,
)
snapshot = SirSnapshot(
snapshot_id="snapshot.demo", project_id="demo", nodes=[module, procedure], edges=[edge]
)
validate_snapshot(snapshot)
def test_snapshot_validation_dangling_edge():
module, _procedure = make_nodes()
edge = SemanticEdge(
edge_id="edge.bad.1",
kind=EdgeKind.CALLS,
source_lineage=module.lineage_id,
target_lineage="lineage.missing",
)
snapshot = SirSnapshot(snapshot_id="snapshot.demo", project_id="demo", nodes=[module], edges=[edge])
with pytest.raises(SnapshotValidationError):
validate_snapshot(snapshot)
+3
View File
@@ -0,0 +1,3 @@
# sfera-storage-core
File-backed storage for SFERA semantic snapshots and local stand state.
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-storage-core"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,131 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import orjson
from pydantic import BaseModel
from sir import SirSnapshot, snapshot_from_json, snapshot_to_json
class StoredSnapshotInfo(BaseModel):
project_id: str
snapshot_id: str
snapshot_hash: str | None = None
path: str
class FileStorage:
def __init__(self, root: str | Path = ".sfera/storage") -> None:
self.root = Path(root)
self.snapshots_dir = self.root / "snapshots"
def initialize(self) -> None:
self.snapshots_dir.mkdir(parents=True, exist_ok=True)
def save_snapshot(self, snapshot: SirSnapshot) -> StoredSnapshotInfo:
self.initialize()
path = self._snapshot_path(snapshot.project_id)
path.write_bytes(snapshot_to_json(snapshot))
return StoredSnapshotInfo(
project_id=snapshot.project_id,
snapshot_id=snapshot.snapshot_id,
snapshot_hash=snapshot.snapshot_hash,
path=path.as_posix(),
)
def load_snapshot(self, project_id: str) -> SirSnapshot:
path = self._snapshot_path(project_id)
if not path.exists():
raise FileNotFoundError(project_id)
return snapshot_from_json(path.read_bytes())
def list_snapshots(self) -> list[StoredSnapshotInfo]:
self.initialize()
result: list[StoredSnapshotInfo] = []
for path in sorted(self.snapshots_dir.glob("*.json")):
snapshot = snapshot_from_json(path.read_bytes())
result.append(
StoredSnapshotInfo(
project_id=snapshot.project_id,
snapshot_id=snapshot.snapshot_id,
snapshot_hash=snapshot.snapshot_hash,
path=path.as_posix(),
)
)
return result
def has_snapshot(self, project_id: str) -> bool:
return self._snapshot_path(project_id).exists()
def write_document(self, collection: str, document_id: str, payload: dict[str, Any]) -> Path:
directory = self._collection_dir(collection)
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{self._safe_name(document_id)}.json"
path.write_bytes(orjson.dumps(payload, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS))
return path
def read_document(self, collection: str, document_id: str) -> dict[str, Any]:
path = self._collection_dir(collection) / f"{self._safe_name(document_id)}.json"
if not path.exists():
raise FileNotFoundError(f"{collection}/{document_id}")
return orjson.loads(path.read_bytes())
def delete_document(self, collection: str, document_id: str) -> bool:
path = self._collection_dir(collection) / f"{self._safe_name(document_id)}.json"
if not path.exists():
return False
path.unlink()
return True
def delete_snapshot(self, project_id: str) -> bool:
path = self._snapshot_path(project_id)
if not path.exists():
return False
path.unlink()
return True
def delete_documents_matching(self, collection: str, predicate) -> int:
directory = self._collection_dir(collection)
if not directory.exists():
return 0
deleted = 0
for path in sorted(directory.glob("*.json")):
try:
payload = orjson.loads(path.read_bytes())
except (FileNotFoundError, orjson.JSONDecodeError):
continue
if predicate(payload):
path.unlink()
deleted += 1
return deleted
def list_documents(self, collection: str, *, limit: int | None = None) -> list[dict[str, Any]]:
directory = self._collection_dir(collection)
if not directory.exists():
return []
documents: list[dict[str, Any]] = []
for path in sorted(directory.glob("*.json")):
try:
documents.append(orjson.loads(path.read_bytes()))
except (FileNotFoundError, orjson.JSONDecodeError):
continue
if limit is not None and len(documents) >= limit:
break
return documents
def _snapshot_path(self, project_id: str) -> Path:
return self.snapshots_dir / f"{self._safe_name(project_id)}.json"
def _collection_dir(self, collection: str) -> Path:
return self.root / self._safe_name(collection)
def _safe_name(self, value: str) -> str:
return "".join(
character if character.isalnum() or character in {"-", "_", "."} else "_"
for character in value
)
__all__ = ["FileStorage", "StoredSnapshotInfo"]
@@ -0,0 +1,28 @@
from pathlib import Path
from semantic_kernel import index_project
from storage_core import FileStorage
def test_file_storage_saves_and_loads_snapshot(tmp_path: Path):
source_dir = tmp_path / "src"
source_dir.mkdir()
(source_dir / "module.bsl").write_text("Процедура Test()\nКонецПроцедуры\n", encoding="utf-8")
snapshot = index_project(source_dir, project_id="demo")
storage = FileStorage(tmp_path / "storage")
info = storage.save_snapshot(snapshot)
restored = storage.load_snapshot("demo")
assert info.project_id == "demo"
assert restored.snapshot_hash == snapshot.snapshot_hash
assert storage.list_snapshots()[0].snapshot_id == snapshot.snapshot_id
def test_file_storage_generic_documents(tmp_path: Path):
storage = FileStorage(tmp_path / "storage")
storage.write_document("knowledge", "record/1", {"record_id": "record/1", "title": "Demo"})
assert storage.read_document("knowledge", "record/1")["title"] == "Demo"
assert storage.list_documents("knowledge")[0]["record_id"] == "record/1"
+3
View File
@@ -0,0 +1,3 @@
# sfera-transaction-topology
Deterministic transaction write topology derived from SIR `WRITES` edges.
@@ -0,0 +1,11 @@
[project]
name = "sfera-transaction-topology"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,62 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from sir import EdgeKind, SemanticNode, SirSnapshot
class TransactionWriteSet(BaseModel):
routine: SemanticNode
writes: list[SemanticNode] = Field(default_factory=list)
class TransactionTargetWriters(BaseModel):
target: SemanticNode
routines: list[SemanticNode] = Field(default_factory=list)
def transaction_write_sets(snapshot: SirSnapshot) -> list[TransactionWriteSet]:
nodes = {node.lineage_id: node for node in snapshot.nodes}
result: dict[str, TransactionWriteSet] = {}
for edge in snapshot.edges:
if edge.kind != EdgeKind.WRITES:
continue
routine = nodes.get(edge.source_lineage)
target = nodes.get(edge.target_lineage)
if routine is None or target is None:
continue
result.setdefault(routine.lineage_id, TransactionWriteSet(routine=routine)).writes.append(target)
write_sets = list(result.values())
write_sets.sort(key=lambda item: item.routine.qualified_name)
return write_sets
def transaction_targets(snapshot: SirSnapshot) -> list[TransactionTargetWriters]:
result: dict[str, TransactionTargetWriters] = {}
for write_set in transaction_write_sets(snapshot):
for target in write_set.writes:
result.setdefault(target.lineage_id, TransactionTargetWriters(target=target)).routines.append(write_set.routine)
targets = list(result.values())
targets.sort(key=lambda item: item.target.qualified_name)
return targets
def routines_touching_target(snapshot: SirSnapshot, target_name: str) -> list[SemanticNode]:
wanted = target_name.casefold()
routines: list[SemanticNode] = []
for write_set in transaction_write_sets(snapshot):
if any(
target.name.casefold() == wanted or target.qualified_name.casefold() == wanted
for target in write_set.writes
):
routines.append(write_set.routine)
return routines
__all__ = [
"TransactionTargetWriters",
"TransactionWriteSet",
"routines_touching_target",
"transaction_targets",
"transaction_write_sets",
]
@@ -0,0 +1,46 @@
from pathlib import Path
from semantic_kernel import index_project
from transaction_topology import routines_touching_target, transaction_targets, transaction_write_sets
def test_transaction_write_sets_find_register_writes(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="demo")
write_sets = transaction_write_sets(snapshot)
assert write_sets[0].routine.name == "Проведение"
assert write_sets[0].writes[0].name == "ОстаткиТоваров"
assert routines_touching_target(snapshot, "ОстаткиТоваров")[0].name == "Проведение"
def test_transaction_targets_group_multiple_writers_by_register(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура Корректировка()
Набор = РегистрыНакопления.ОстаткиТоваров.СоздатьНаборЗаписей();
Набор.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="transaction-targets")
targets = transaction_targets(snapshot)
assert [target.target.qualified_name for target in targets] == ["РегистрНакопления.ОстаткиТоваров"]
assert {routine.name for routine in targets[0].routines} == {"Проведение", "Корректировка"}
+3
View File
@@ -0,0 +1,3 @@
# sfera-ui-semantics
UI structure helpers over SIR form, command, and element nodes.
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sfera-ui-semantics"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sfera-sir",
]
[tool.uv]
package = true
@@ -0,0 +1,46 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from sir import EdgeKind, NodeKind, SemanticNode, SirSnapshot
class FormSemantics(BaseModel):
form: SemanticNode
commands: list[SemanticNode] = Field(default_factory=list)
elements: list[SemanticNode] = Field(default_factory=list)
command_handlers: dict[str, SemanticNode] = Field(default_factory=dict)
def form_semantics(snapshot: SirSnapshot) -> list[FormSemantics]:
nodes = {node.lineage_id: node for node in snapshot.nodes}
forms = {
node.lineage_id: FormSemantics(form=node)
for node in snapshot.nodes
if node.kind == NodeKind.FORM
}
for edge in snapshot.edges:
form = forms.get(edge.source_lineage)
target = nodes.get(edge.target_lineage)
if form is None or target is None:
continue
if edge.kind == EdgeKind.HAS_COMMAND:
form.commands.append(target)
elif edge.kind == EdgeKind.HAS_ELEMENT:
form.elements.append(target)
command_to_form = {
command.lineage_id: form
for form in forms.values()
for command in form.commands
}
for edge in snapshot.edges:
if edge.kind != EdgeKind.HANDLES:
continue
form = command_to_form.get(edge.source_lineage)
handler = nodes.get(edge.target_lineage)
if form is not None and handler is not None:
form.command_handlers[edge.source_lineage] = handler
return sorted(forms.values(), key=lambda item: item.form.qualified_name)
__all__ = ["FormSemantics", "form_semantics"]

Some files were not shown because too many files have changed in this diff Show More