Initial SFERA platform baseline
This commit is contained in:
@@ -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.
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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] == ["Период"]
|
||||
@@ -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
|
||||
@@ -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 == "ОбменСКассой"
|
||||
@@ -0,0 +1,3 @@
|
||||
# sfera-job-topology
|
||||
|
||||
Scheduled job inventory and links to semantic routines.
|
||||
@@ -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 == "ОбновитьЦены"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
# sfera-one-c-normalizer
|
||||
|
||||
Нормализация исходников и метаданных 1С для SFERA.
|
||||
|
||||
Текущий пакет содержит:
|
||||
|
||||
- нормализацию BSL-файлов;
|
||||
- первичный парсер XML-выгрузок метаданных;
|
||||
- каталог типов метаданных 1С для построения дерева SFERA IDE.
|
||||
|
||||
Опорная модель дерева описана в `docs/1c-metadata-structure.md`.
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
# sfera-operations-core
|
||||
|
||||
Operations primitives for jobs, observability, reports, marketplace inventory, and licensing.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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] == ["Проведение"]
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
# sfera-runtime-overlays
|
||||
|
||||
Runtime observations attached to semantic lineage IDs.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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)}"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
# sfera-storage-core
|
||||
|
||||
File-backed storage for SFERA semantic snapshots and local stand state.
|
||||
@@ -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"
|
||||
@@ -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} == {"Проведение", "Корректировка"}
|
||||
@@ -0,0 +1,3 @@
|
||||
# sfera-ui-semantics
|
||||
|
||||
UI structure helpers over SIR form, command, and element nodes.
|
||||
@@ -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
Reference in New Issue
Block a user