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
+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] == ["Период"]