diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..196576e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +Все значимые изменения **tools/index** (индекс метаданных и модулей 1С) фиксируются в этом файле. + +Формат: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Версионирование: [Semantic Versioning](https://semver.org/lang/ru/). + +## [0.2.0] - 2026-07-16 + +### Added + +- Команда **`query_1c.py object`** — поиск по имени/синониму объекта или реквизита и вывод дерева структуры: + - реквизиты, измерения, ресурсы, табличные части и колонки; + - обязательность заполнения (`FillChecking`: обяз. / необяз.); + - типы данных и ссылочные типы (`CatalogRef.*`, `DocumentRef.*`, `DefinedType.*`, …); + - опция **`--clear`** — только найденные реквизиты или только заголовок объекта. +- Опция **`query_1c.py modules --names-only` / `-n`** — список модулей (owner + синоним) без фрагментов кода. +- В **`refs`**: лимит **на каждую** конфигурацию; группировка по baseconf; пометка **«среди прочего»** для составных типов. +- Разбор **`TypeSet`** (определяемые типы) и **`FillChecking`** в парсере метаданных. +- Файлы **`VERSION`**, **`CHANGELOG.md`**; флаг **`--version`** у `index_1c.py` и `query_1c.py`. + +### Changed + +- Параметр конфигурации 1С: **`--baseconf` / `-b`** (алиасы `target`→`crm3-26`, `source`→`crm3-dev`). +- **`--config` / `-c`** — только файл настроек утилиты (JSON), не выгрузка 1С. +- Информативные ошибки: нет индекса / нет папки `src/` / baseconf не проиндексирован / объект не найден (с подсказками). +- Расширена документация `README.md` (примеры, режимы, применение в Cursor). + +### Fixed + +- `refs` без `-b` больше не ограничивался первой конфигурацией из‑за `ORDER BY` + `LIMIT`. +- Фильтры `-b` / `--type` в `object`: скобки в SQL (`OR` vs `AND`). + +## [0.1.0] - 2026-07-15 + +### Added + +- Единый индекс проекта `cache/1c_meta/index.sqlite` (объекты, поля, ссылки, модули BSL, FTS5). +- `index_1c.py`: `reindex` (full / incremental), `status`, `list`; многопоточный разбор (`-j`). +- `query_1c.py`: `search`, `show`, `refs`, `modules`, `module`. +- Инкремент по `mtime`+`size` файлов; индекс модулей `**/*.bsl`. + +[0.2.0]: https://git.p7net.ru/1c/index/-/tags/v0.2.0 +[0.1.0]: https://git.p7net.ru/1c/index/-/tags/v0.1.0 diff --git a/README.md b/README.md index 961bd18..a4e6a5f 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,61 @@ # Индекс метаданных и модулей конфигураций 1С -Единый кэш: **`cache/1c_meta/index.sqlite`** — все выгрузки 1С проекта в одной БД (FTS5). +**Версия:** см. [`VERSION`](VERSION) (текущая: **0.2.0**) · [CHANGELOG](CHANGELOG.md) + +Единый кэш проекта: **`cache/1c_meta/index.sqlite`** — все выгрузки 1С в одной БД (SQLite FTS5). | Скрипт | Назначение | |--------|------------| | [`index_1c.py`](index_1c.py) | построение и обновление индекса | -| [`query_1c.py`](query_1c.py) | поиск по готовому индексу | +| [`query_1c.py`](query_1c.py) | поиск и просмотр по готовому индексу | -Документация для Cursor: [`.cursor/rules/1c-meta-index.mdc`](../.cursor/rules/1c-meta-index.mdc) +Репозиторий утилиты: +Правило для Cursor (в корне CRM3-26): `.cursor/rules/1c-meta-index.mdc` ---- - -## Параметры командной строки - -| Параметр | Короткий | Что это | -|----------|----------|---------| -| `--config` | `-c` | **Файл настроек утилиты** (JSON), см. [`config.example.json`](config.example.json). Не конфигурация 1С. | -| `--root` | `-r` | Корень проекта CRM3-26 (если скрипт запущен не из дерева проекта). | -| `--baseconf` | `-b` | **Выгрузка конфигурации 1С** в репозитории — папка `/src/`. Можно указывать несколько раз. | - -### Алиасы `--baseconf` - -| Алиас | Папка в репозитории | -|-------|---------------------| -| `target`, `crm3_26` | `crm3-26` | -| `source`, `crm3_old`, `crm3-old`, `crm3_dev` | `crm3-dev` | - -Список выгрузок и алиасов: `python tools/index/index_1c.py list` +```bash +python tools/index/index_1c.py --version +python tools/index/query_1c.py --version +``` --- ## Зачем это нужно -При работе в Cursor поиск по XML/BSL всего дерева `crm3-26/src` (десятки тысяч файлов) медленный. Индекс один раз разбирает метаданные и модули и даёт: +Обход десятков тысяч XML/BSL в `crm3-26/src` и соседних выгрузках через Grep в Cursor медленный. Индекс один раз разбирает метаданные и модули и даёт: -- поиск объектов по имени, синониму, полям; -- обратный индекс ссылок (`refs`); -- поиск по BSL (процедуры, фрагменты кода); -- фильтр по конкретной выгрузке (`-b crm3-26`). +| Задача | Команда | +|--------|---------| +| Найти объект по имени/синониму | `query_1c.py search` / `object` | +| Структура реквизитов, обязательность, типы | `query_1c.py object` | +| Кто ссылается на справочник/документ | `query_1c.py refs` | +| Найти процедуру/фрагмент в BSL | `query_1c.py modules` | +| Сравнить целевую и исходную конфигурацию | `-b target` / `-b source` | -**Типичный workflow агента:** `query_1c.py search` → `show` / `refs` → открыть конкретный `.bsl` или `.xml`, без полного Grep по конфигурации. +**Типичный workflow агента:** +`reindex` (после выгрузки) → `object` / `search` → `refs` / `modules` → открыть конкретный `.xml` / `.bsl`. + +--- + +## Параметры командной строки (общие) + +| Параметр | Короткий | Назначение | +|----------|----------|------------| +| `--config` | `-c` | Файл **настроек утилиты** (JSON). **Не** конфигурация 1С. См. [`config.example.json`](config.example.json). | +| `--root` | `-r` | Корень проекта CRM3-26 (если запуск не из дерева проекта). | +| `--baseconf` | `-b` | Выгрузка конфигурации 1С: папка `/src/` в репозитории. Можно несколько раз. | +| `--version` | | Версия утилиты. | + +Приоритет корня проекта: `--root` > `project_root` в `--config` > авто из расположения `tools/index/`. + +### Алиасы `--baseconf` + +| Алиас | Папка | +|-------|-------| +| `target`, `crm3_26` | `crm3-26` | +| `source`, `crm3_old`, `crm3-old`, `crm3_dev` | `crm3-dev` | + +Другие выгрузки без алиаса: `crm3-26.rhana`, `crm3-dev.rhana`, `ws-rhana`, `bu-corp`, … +Полный список: `python tools/index/index_1c.py list` --- @@ -49,107 +65,158 @@ | Команда | Описание | |---------|----------| -| `reindex` | обновить индекс (по умолчанию только изменившиеся файлы) | -| `status` | размер БД, метаданные, список проиндексированных baseconf | -| `list` | какие `/src/` есть в репозитории и что уже в индексе | +| `reindex` | обновить индекс | +| `status` | размер БД, meta, список проиндексированных baseconf | +| `list` | выгрузки с `src/` в репозитории + что уже в индексе | ### Режимы `reindex` | Вызов | Поведение | |-------|-----------| -| `reindex -j 12` | инкремент всех известных выгрузок с `src/` | -| `reindex --full -j 12` | удалить `index.sqlite` и собрать заново | -| `reindex --full -b crm3-26` | пересобрать только `crm3-26` (остальные в БД сохраняются) | -| `reindex -b target -t Documents,Catalogs` | только документы и справочники целевой конфигурации | -| `reindex -b ws-rhana --no-modules` | только XML метаданных, без `.bsl` | +| `reindex -j 12` | инкремент: только файлы с изменившимися `mtime`/`size` | +| `reindex --full -j 12` | удалить всю БД и собрать заново все известные выгрузки | +| `reindex --full -b crm3-26` | пересобрать только указанную конфигурацию | +| `reindex -b target -t Documents,Catalogs` | только выбранные типы объектов | +| `reindex -b ws-rhana --no-modules` | метаданные без BSL | +| `reindex --md` | дополнительно Markdown в `cache/1c_meta/md//` | -Дополнительно: `--md` — экспорт Markdown в `cache/1c_meta/md//` для `@` в чате. +`-j` / `--workers` — число потоков (по умолчанию 4–16). -### Что попадает в индекс +### Что индексируется -- **Метаданные:** `src/<ТипObjects>/*.xml` — имя, синоним, реквизиты, измерения, типы, ссылки. -- **Модули:** все `**/*.bsl` — путь, владелец (`Document.ЗаказКлиента`), процедуры/функции, тело (до 200 КБ в FTS). +- **Метаданные** `src/<Тип>/*.xml`: имя, синоним, комментарий, реквизиты / измерения / ресурсы / ТЧ, типы (в т.ч. `TypeSet`), ссылки, `FillChecking`. +- **Модули** `**/*.bsl`: путь, owner (`Document.X`), вид модуля, имена процедур/функций, тело (до 200 КБ в FTS). -Пропускаются по умолчанию: картинки, стили, XDTO, шаблоны, языки. +По умолчанию пропускаются: `CommonPicture`, `StyleItem`, `XDTOPackage`, `CommonTemplate`, `Language`, `Bot`. + +### Примеры переиндексации + +```bash +# после обновления выгрузки целевой конфигурации +python tools/index/index_1c.py reindex -b target -j 12 + +# полная пересборка всего проекта +python tools/index/index_1c.py reindex --full -j 12 + +# только документы и справочники исходной + расширения +python tools/index/index_1c.py reindex -b source -b crm3-dev.rhana -t Documents,Catalogs -j 8 + +python tools/index/index_1c.py status +python tools/index/index_1c.py list +``` --- ## `query_1c.py` — поиск -Требует готовый `cache/1c_meta/index.sqlite` (см. `index_1c.py reindex`). +Нужен готовый `cache/1c_meta/index.sqlite`. ### Команды -| Команда | Аргумент | Результат | -|---------|----------|-----------| -| `search` | строка запроса | объекты метаданных (FTS) | -| `show` | имя объекта | полная карточка JSON | -| `refs` | `Catalog.Партнеры` | кто ссылается полем-ссылкой | -| `modules` | строка | поиск по BSL | -| `module` | путь или owner | метаданные модуля (символы, строки) | +| Команда | Аргумент | Результат | Полезные опции | +|---------|----------|-----------|----------------| +| `search` | строка | объекты метаданных (FTS) | `-b`, `--type`, `--limit` | +| `object` | имя/синоним объекта **или** реквизита | дерево структуры | `-b`, `--type`, `--clear`, `--limit` | +| `show` | имя объекта | полная карточка JSON | `-b` | +| `refs` | `Catalog.Партнеры` / `ЗаказКлиента` | обратные ссылки полей | `-b`, `--limit` (на каждую baseconf) | +| `modules` | строка | поиск по BSL | `-b`, `-n` / `--names-only`, `--limit` | +| `module` | путь или owner | символы модуля, строки | `-b` | -### Примеры +### `object` — дерево реквизитов ```bash -# все конфигурации в индексе -python tools/index/query_1c.py search "ЗаказКлиента" +# полная структура документа +python tools/index/query_1c.py object ЗаказКлиента -b target -# только целевая (алиас target) -python tools/index/query_1c.py show ЗаказКлиента -b target -python tools/index/query_1c.py show Document.ЗаказКлиента -b crm3-26 +# только реквизиты с именем/синонимом «Партнер» в документах +python tools/index/query_1c.py object Партнер -b target --type Document --clear -# исходная + расширение -python tools/index/query_1c.py search "Партнер" -b source -b crm3-dev.rhana - -# модули ws-rhana -python tools/index/query_1c.py modules "ОбработатьHealth" -b ws-rhana - -# обратные ссылки в целевой -python tools/index/query_1c.py refs Catalog.Партнеры -b target --limit 20 +# совпал сам объект → --clear печатает только заголовок +python tools/index/query_1c.py object ЗаказКлиента -b target --clear ``` -### Ошибки +Формат строки реквизита: -- **Нет индекса** — подсказка запустить `index_1c.py reindex`. -- **`-b unknown`** — список папок в репозитории и алиасы. -- **`-b crm3-26`, но не проиндексировано** — список того, что есть в БД, и команда переиндексации. -- **`show` не нашёл объект** — похожие имена из индекса. +```text +├── `Attribute.Партнер` — Клиент [обяз.] : CatalogRef.Партнеры +├── `Attribute.СуммаДокумента` — … [необяз.] : DefinedType.ДенежнаяСуммаЛюбогоЗнака +├── `TabularSection.Товары` — Товары +│ └── `Column.Номенклатура` — … [обяз.] : CatalogRef.Номенклатура +``` + +- **[обяз.]** — `FillChecking=ShowError`; **[необяз.]** — `DontCheck` +- составной тип: несколько вариантов через `|` +- при наличии XML структура подтягивается с диска (актуальный `FillChecking`) + +### `refs` — кто ссылается + +```bash +python tools/index/query_1c.py refs "ЗаказКлиента" +python tools/index/query_1c.py refs Catalog.Партнеры -b target --limit 50 +python tools/index/query_1c.py refs Document.ЗаказКлиента -b source +``` + +Без `-b` — все конфигурации в индексе; `--limit` действует **на каждую**. +Составной тип поля: `→ DocumentRef.ЗаказКлиента (среди прочего: DocumentRef.…)`. + +### `modules` / `module` + +```bash +python tools/index/query_1c.py modules "ОбработатьHealth" -b ws-rhana +python tools/index/query_1c.py modules "РВС_Transfer" -b ws-rhana -n +python tools/index/query_1c.py module CommonModules/РВС_TransferAPIДокументы -b ws-rhana +``` + +### Прочие примеры + +```bash +python tools/index/query_1c.py search "ЗаказКлиента" +python tools/index/query_1c.py search "Партнер" --type Document -b crm3-26 +python tools/index/query_1c.py show Document.ЗаказКлиента -b target +python tools/index/query_1c.py search "Партнер" -b source -b crm3-dev.rhana +``` + +### Ошибки и подсказки + +| Ситуация | Поведение | +|----------|-----------| +| Нет `index.sqlite` | подсказка `index_1c.py reindex --full` | +| `-b` без папки `src/` | список доступных выгрузок + алиасы | +| `-b` есть в репо, нет в индексе | список проиндексированных + команда `reindex -b …` | +| `show` / `object` не нашли | похожие имена из индекса | --- ## Файл настроек утилиты (`--config`) -Для нестандартного расположения проекта: - ```bash cp tools/index/config.example.json tools/index/config.local.json -# отредактировать project_root +# project_root: "/path/to/crm3-26" python tools/index/query_1c.py --config tools/index/config.local.json search "Заказ" ``` -Приоритет корня: `--root` > `project_root` в JSON > автоопределение от `tools/index/`. +`config.local.json` в `.gitignore` (локальные пути). --- ## Применение в Cursor -1. После выгрузки из EDT/конфигуратора: - `python tools/index/index_1c.py reindex -j 12` - или `reindex -b target` если менялась только целевая. -2. Агент ищет через `query_1c.py`, не через Grep по `src/`. -3. Уточнение контекста: `-b target` / `-b source` чтобы не смешивать старую и новую конфигурацию. -4. Для кода: `modules` → открыть найденный `rel_path` в репозитории. +1. После выгрузки из EDT/конфигуратора: `python tools/index/index_1c.py reindex -j 12` (или `-b target`). +2. Метаданные и связи — через `query_1c.py`, не Grep по всему `src/`. +3. Разделять контекст: `-b target` vs `-b source`. +4. Код: `modules` → открыть `rel_path`; структура документа: `object`. +5. Правило агента: `.cursor/rules/1c-meta-index.mdc`. --- -## Артефакты +## Артефакты кэша -``` -cache/1c_meta/ - index.sqlite # единая БД - INDEX.md # сводка последнего reindex +```text +cache/1c_meta/ # в корне проекта CRM3-26 (gitignore) + index.sqlite # единая БД + INDEX.md manifest.json - md// # опционально (--md) + md// # опционально (--md) ``` -Старые per-config `search.sqlite` в подкаталогах не используются. +Per-config `search.sqlite` устарели и не используются. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/index1c/__init__.py b/index1c/__init__.py index e9c3efc..e9eb038 100644 --- a/index1c/__init__.py +++ b/index1c/__init__.py @@ -1,3 +1,14 @@ -"""Индексация метаданных конфигураций 1С (выгрузки EDT/XML) для быстрого поиска.""" +"""Индексация метаданных и модулей конфигураций 1С (выгрузки EDT/XML).""" -__version__ = "0.1.0" +from pathlib import Path + +__version__ = "0.2.0" +__status__ = "in development" + + +def read_version_file() -> str: + """Версия из файла VERSION рядом со скриптами (источник истины для релиза).""" + path = Path(__file__).resolve().parents[1] / "VERSION" + if path.is_file(): + return path.read_text(encoding="utf-8").strip() or __version__ + return __version__ diff --git a/index1c/object_view.py b/index1c/object_view.py new file mode 100644 index 0000000..2f3e271 --- /dev/null +++ b/index1c/object_view.py @@ -0,0 +1,361 @@ +"""Поиск объекта/реквизита и вывод дерева структуры метаданных.""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + +from .parse_meta import FieldRec, ObjectRec, parse_metadata_xml + + +FILL_LABEL = { + "ShowError": "обяз.", + "DontCheck": "необяз.", +} + + +def _norm(s: str) -> str: + return " ".join((s or "").casefold().split()) + + +def _type_label(raw: str) -> str: + if raw.startswith("cfg:"): + return raw[4:] + if raw.startswith("xs:"): + return raw[3:] + return raw + + +def _fill_label(fill: str) -> str: + if not fill: + return "?" + return FILL_LABEL.get(fill, fill) + + +def _field_matches(f: dict[str, Any], q: str, qn: str) -> bool: + """Только точное совпадение имени или синонима реквизита.""" + name_cf = _norm(f.get("name") or "") + syn_cf = _norm(f.get("synonym") or "") + # синоним может быть «Клиент | en:Client» — сравниваем по частям + syn_parts = [_norm(p) for p in (f.get("synonym") or "").split("|")] + return name_cf == qn or syn_cf == qn or qn in syn_parts + + +def find_structure_matches( + conn: sqlite3.Connection, + query: str, + *, + configs: list[str] | None = None, + object_type: str | None = None, + limit: int = 20, +) -> list[dict[str, Any]]: + """Найти объекты по имени/синониму объекта или реквизита. + + Каждый элемент: + config, full_name, path, json, match_kind: object|field, + matched_fields: list[field dict keys], + score: int (меньше — лучше) + """ + q = query.strip() + qn = _norm(q) + if not qn: + return [] + + sql_obj = """ + SELECT id, config, full_name, name, synonym, path, json, object_type + FROM objects + WHERE ( + name = ? OR full_name = ? OR full_name LIKE ? + OR synonym LIKE ? OR name LIKE ? + ) + """ + params: list[Any] = [q, q, f"%.{q}", f"%{q}%", f"%{q}%"] + if configs: + sql_obj += f" AND config IN ({','.join('?' * len(configs))})" + params.extend(configs) + if object_type: + sql_obj += " AND object_type = ?" + params.append(object_type) + sql_obj += " ORDER BY config, full_name LIMIT ?" + params.append(max(limit * 5, 50)) + + by_key: dict[tuple[str, str], dict[str, Any]] = {} + + def _ensure(row) -> dict[str, Any]: + key = (row["config"], row["full_name"]) + if key not in by_key: + oid = row["object_id"] if "object_id" in row.keys() else row["id"] + by_key[key] = { + "config": row["config"], + "full_name": row["full_name"], + "path": row["path"], + "json": row["json"], + "object_type": row["object_type"], + "match_kind": "none", + "matched_fields": [], + "object_id": oid, + "score": 100, + } + return by_key[key] + + for row in conn.execute(sql_obj, params): + entry = _ensure(row) + name_cf = _norm(row["name"]) + full_cf = _norm(row["full_name"]) + syn_cf = _norm(row["synonym"] or "") + if name_cf == qn or full_cf == qn or full_cf.endswith("." + qn): + entry["match_kind"] = "object" + entry["score"] = min(entry["score"], 0) + elif syn_cf == qn: + entry["match_kind"] = "object" + entry["score"] = min(entry["score"], 2) + elif qn in name_cf or qn in full_cf: + if entry["match_kind"] == "none": + entry["match_kind"] = "object" + entry["score"] = min(entry["score"], 20) + elif qn in syn_cf: + if entry["match_kind"] == "none": + entry["match_kind"] = "object" + entry["score"] = min(entry["score"], 25) + + sql_f = """ + SELECT o.id AS object_id, o.config, o.full_name, o.path, o.json, o.object_type, + o.name AS object_name, o.synonym AS object_synonym, + f.kind, f.name, f.synonym, f.types, f.tabular_section, f.refs_json + FROM fields f + JOIN objects o ON o.id = f.object_id + WHERE ( + f.name = ? OR f.synonym LIKE ? OR f.name LIKE ? + ) + """ + params_f: list[Any] = [q, f"%{q}%", f"%{q}%"] + if configs: + sql_f += f" AND o.config IN ({','.join('?' * len(configs))})" + params_f.extend(configs) + if object_type: + sql_f += " AND o.object_type = ?" + params_f.append(object_type) + sql_f += " ORDER BY o.config, o.full_name LIMIT ?" + params_f.append(max(limit * 15, 100)) + + for row in conn.execute(sql_f, params_f): + fdict = { + "kind": row["kind"], + "name": row["name"], + "synonym": row["synonym"] or "", + "types": [t.strip() for t in (row["types"] or "").split(",") if t.strip()], + "tabular_section": row["tabular_section"] or "", + "refs": json.loads(row["refs_json"] or "[]"), + "fill_checking": "", + } + if not _field_matches(fdict, q, qn): + continue + entry = _ensure(row) + fname_cf = _norm(fdict["name"]) + fsyn_cf = _norm(fdict["synonym"]) + if fname_cf == qn: + field_score = 5 + elif fsyn_cf == qn: + field_score = 6 + else: + field_score = 15 + # если объект уже найден точно — оставляем object, но добавляем поля + if entry["score"] > field_score or entry["match_kind"] in {"none", "field"}: + if entry["match_kind"] != "object" or entry["score"] > 0: + entry["match_kind"] = "field" + entry["score"] = min(entry["score"], field_score) + elif entry["match_kind"] == "object" and entry["score"] == 0: + # точный объект + ещё реквизит с тем же именем (редко) — остаётся object + pass + else: + entry["score"] = min(entry["score"], field_score) + + sig = (fdict["tabular_section"], fdict["name"], fdict["kind"]) + existing = { + (x.get("tabular_section"), x.get("name"), x.get("kind")) + for x in entry["matched_fields"] + } + if sig not in existing: + entry["matched_fields"].append(fdict) + + # убрать слабые «просто LIKE по имени объекта», если есть точные хиты + items = [v for v in by_key.values() if v["match_kind"] != "none"] + has_exact = any(v["score"] <= 6 for v in items) + if has_exact: + items = [v for v in items if v["score"] <= 15] + + items.sort(key=lambda x: (x["score"], x["config"], x["full_name"])) + return items[:limit] + + +def load_object_structure(match: dict[str, Any]) -> dict[str, Any]: + """Загрузить структуру: предпочесть свежий разбор XML (FillChecking).""" + path_s = match.get("path") or "" + path = Path(path_s) if path_s else None + config = match["config"] + otype = match.get("object_type") or match["full_name"].split(".", 1)[0] + + if path and path.is_file(): + rec = parse_metadata_xml(path, config=config, object_type=otype) + if rec: + return rec.to_dict() + + data = json.loads(match["json"]) + # старые индексы без fill_checking + for f in data.get("fields") or []: + f.setdefault("fill_checking", "") + return data + + +def format_types(types: list[str], refs: list[dict[str, Any]] | None = None) -> str: + if not types: + return "—" + labels = [_type_label(t) for t in types] + if len(labels) == 1: + return labels[0] + return " | ".join(labels) + + +def format_field_line( + f: dict[str, Any], + *, + indent: str = "", + last: bool = True, + under_ts: bool = False, +) -> str: + branch = "└── " if last else "├── " + name = f.get("name") or "" + kind = f.get("kind") or "Attribute" + syn = f.get("synonym") or "" + syn_s = f" — {syn}" if syn else "" + if kind == "TabularSection": + return f"{indent}{branch}`{kind}.{name}`{syn_s}" + fill = _fill_label(f.get("fill_checking") or "") + types_s = format_types(f.get("types") or [], f.get("refs")) + if kind == "Column" and not under_ts and f.get("tabular_section"): + name = f"{f['tabular_section']}.{name}" + return f"{indent}{branch}`{kind}.{name}`{syn_s} [{fill}] : {types_s}" + + +def format_object_tree( + obj: dict[str, Any], + *, + clear: bool = False, + matched_fields: list[dict[str, Any]] | None = None, + match_kind: str = "object", +) -> str: + """Дерево реквизитов объекта. clear — только совпадения.""" + lines: list[str] = [] + syn = obj.get("synonym") or "" + syn_s = f" — {syn}" if syn else "" + cfg = obj.get("config") or obj.get("_config") or "" + header = f"`{obj.get('full_name') or obj.get('object_type') + '.' + obj.get('name')}`{syn_s}" + if cfg: + header = f"[{cfg}] {header}" + lines.append(header) + + fields: list[dict[str, Any]] = list(obj.get("fields") or []) + + if clear: + if match_kind == "field" and matched_fields: + want: set[tuple[str, str, str]] = set() + need_ts: set[str] = set() + for mf in matched_fields: + want.add( + ( + mf.get("kind") or "", + mf.get("tabular_section") or "", + mf.get("name") or "", + ) + ) + if mf.get("tabular_section") and mf.get("kind") == "Column": + need_ts.add(mf["tabular_section"]) + selected: list[dict[str, Any]] = [] + for f in fields: + key = ( + f.get("kind") or "", + f.get("tabular_section") or "", + f.get("name") or "", + ) + if key in want: + selected.append(f) + elif f.get("kind") == "TabularSection" and f.get("name") in need_ts: + selected.append(f) + if not selected: + lines.append(" (совпавшие реквизиты не найдены в структуре)") + return "\n".join(lines) + lines.extend(_render_field_tree(selected, only_keys=want, need_ts=need_ts)) + return "\n".join(lines) + return "\n".join(lines) + + if not fields: + lines.append(" (нет реквизитов в индексе)") + return "\n".join(lines) + + lines.extend(_render_field_tree(fields)) + return "\n".join(lines) + + +def _render_field_tree( + fields: list[dict[str, Any]], + *, + only_keys: set[tuple[str, str, str]] | None = None, + need_ts: set[str] | None = None, +) -> list[str]: + """Рендер: сначала шапка объекта уже напечатана; здесь реквизиты и ТЧ.""" + lines: list[str] = [] + # группировка: header fields, then TS with columns + headers = [ + f + for f in fields + if f.get("kind") not in {"TabularSection", "Column"} + and not f.get("tabular_section") + ] + tab_sections = [f for f in fields if f.get("kind") == "TabularSection"] + columns_by_ts: dict[str, list[dict[str, Any]]] = {} + for f in fields: + if f.get("kind") == "Column" and f.get("tabular_section"): + columns_by_ts.setdefault(f["tabular_section"], []).append(f) + + if only_keys is not None: + headers = [ + f + for f in headers + if (f.get("kind") or "", f.get("tabular_section") or "", f.get("name") or "") + in only_keys + ] + tab_sections = [ + f + for f in tab_sections + if f.get("name") in (need_ts or set()) + or (f.get("kind") or "", "", f.get("name") or "") in only_keys + ] + + # плоский список узлов верхнего уровня для корректных └──/├── + top: list[tuple[str, Any]] = [("h", f) for f in headers] + [("ts", f) for f in tab_sections] + for i, (kind, node) in enumerate(top): + last = i == len(top) - 1 + if kind == "h": + lines.append(format_field_line(node, indent="", last=last)) + continue + # tabular section + lines.append(format_field_line(node, indent="", last=last)) + ts_name = node.get("name") or "" + cols = columns_by_ts.get(ts_name, []) + if only_keys is not None: + cols = [ + c + for c in cols + if (c.get("kind") or "", c.get("tabular_section") or "", c.get("name") or "") + in only_keys + ] + child_indent = " " if last else "│ " + for j, col in enumerate(cols): + lines.append( + format_field_line( + col, indent=child_indent, last=(j == len(cols) - 1), under_ts=True + ) + ) + return lines diff --git a/index1c/parse_meta.py b/index1c/parse_meta.py index 8628f7c..699be53 100644 --- a/index1c/parse_meta.py +++ b/index1c/parse_meta.py @@ -85,6 +85,10 @@ def collect_types(props: ET.Element) -> list[str]: raw = (t.text or "").strip() if raw: types.append(raw) + for t in typ.findall(f".//{V8}TypeSet"): + raw = (t.text or "").strip() + if raw: + types.append(raw) return types @@ -113,6 +117,7 @@ class FieldRec: types: list[str] = field(default_factory=list) refs: list[dict[str, str]] = field(default_factory=list) tabular_section: str = "" + fill_checking: str = "" # ShowError | DontCheck | … @dataclass @@ -173,6 +178,7 @@ def _parse_field_props(props: ET.Element, kind: str, tabular: str = "") -> Field types=types, refs=parse_ref_targets(types), tabular_section=tabular, + fill_checking=child_text(props, "FillChecking"), ) diff --git a/index1c/search.py b/index1c/search.py index 0cd4a83..a6b3d83 100644 --- a/index1c/search.py +++ b/index1c/search.py @@ -123,14 +123,18 @@ def search_modules( *, configs: list[str] | None = None, limit: int = 30, + names_only: bool = False, ) -> list[dict[str, Any]]: fts = _fts_query(query) - sql = """ + snip_col = "'' AS snip" if names_only else "snippet(modules_fts, 4, '[', ']', '…', 16) AS snip" + sql = f""" SELECT m.config, m.rel_path, m.owner, m.module_kind, m.symbols, m.line_count, - snippet(modules_fts, 4, '[', ']', '…', 16) AS snip, + o.synonym AS owner_synonym, + {snip_col}, bm25(modules_fts, 2.0, 5.0, 2.0, 8.0, 1.0) AS score FROM modules_fts JOIN modules m ON m.id = modules_fts.module_id + LEFT JOIN objects o ON o.config = m.config AND o.full_name = m.owner WHERE modules_fts MATCH ? """ params: list[Any] = [fts] @@ -144,13 +148,15 @@ def search_modules( except sqlite3.OperationalError: like = f"%{query}%" sql2 = """ - SELECT config, rel_path, owner, module_kind, symbols, line_count, '' AS snip, 0 AS score - FROM modules - WHERE rel_path LIKE ? OR owner LIKE ? OR symbols LIKE ? + SELECT m.config, m.rel_path, m.owner, m.module_kind, m.symbols, m.line_count, + o.synonym AS owner_synonym, '' AS snip, 0 AS score + FROM modules m + LEFT JOIN objects o ON o.config = m.config AND o.full_name = m.owner + WHERE m.rel_path LIKE ? OR m.owner LIKE ? OR m.symbols LIKE ? """ params2: list[Any] = [like, like, like] if configs: - sql2 += f" AND config IN ({','.join('?' * len(configs))})" + sql2 += f" AND m.config IN ({','.join('?' * len(configs))})" params2.extend(configs) sql2 += " LIMIT ?" params2.append(limit) @@ -163,7 +169,13 @@ def find_refs_to( *, configs: list[str] | None = None, limit: int = 100, -) -> list[dict[str, Any]]: +) -> tuple[list[dict[str, Any]], dict[str, int]]: + """Обратные ссылки на объект. + + Возвращает (строки, totals_by_config). + При поиске по нескольким конфигурациям ``limit`` — максимум **на каждую** + конфигурацию (чтобы crm3-26 не вытеснял crm3-dev из-за ORDER BY + LIMIT). + """ kind = "" name = target if "." in target: @@ -183,25 +195,82 @@ def find_refs_to( }: kind_ref = kind + "Ref" + where = "r.to_name = ?" + params: list[Any] = [name] if kind_ref: - sql = """ - SELECT config, from_full_name, from_field, to_kind, to_name - FROM refs - WHERE to_name = ? AND (to_kind = ? OR to_kind = ?) - """ - params: list[Any] = [name, kind_ref, kind] - else: - sql = """ - SELECT config, from_full_name, from_field, to_kind, to_name - FROM refs WHERE to_name = ? - """ - params = [name] + where += " AND (r.to_kind = ? OR r.to_kind = ?)" + params.extend([kind_ref, kind]) if configs: - sql += f" AND config IN ({','.join('?' * len(configs))})" + where += f" AND r.config IN ({','.join('?' * len(configs))})" params.extend(configs) - sql += " ORDER BY config, from_full_name LIMIT ?" - params.append(limit) - return [dict(r) for r in conn.execute(sql, params)] + + # Итоги по конфигурациям (без LIMIT) + totals: dict[str, int] = { + r["config"]: int(r["n"]) + for r in conn.execute( + f"SELECT r.config, COUNT(*) AS n FROM refs r WHERE {where} GROUP BY r.config", + params, + ) + } + + # limit на каждую конфигурацию — иначе алфавитный ORDER BY отрезает остальные + sql = f""" + WITH matched AS ( + SELECT + r.config, + r.from_full_name, + r.from_field, + r.to_kind, + r.to_name, + f.types AS field_types, + f.refs_json AS field_refs_json, + ROW_NUMBER() OVER ( + PARTITION BY r.config + ORDER BY r.from_full_name, r.from_field + ) AS rn + FROM refs r + LEFT JOIN objects o + ON o.config = r.config AND o.full_name = r.from_full_name + LEFT JOIN fields f + ON f.object_id = o.id + AND ( + (COALESCE(f.tabular_section, '') = '' AND f.name = r.from_field) + OR (f.tabular_section || '.' || f.name = r.from_field) + ) + WHERE {where} + ) + SELECT * + FROM matched + WHERE rn <= ? + ORDER BY config, from_full_name, from_field + """ + rows = [dict(r) for r in conn.execute(sql, [*params, limit])] + + for row in rows: + types_s = row.get("field_types") or "" + refs_raw = row.get("field_refs_json") or "[]" + try: + refs_list = json.loads(refs_raw) if refs_raw else [] + except json.JSONDecodeError: + refs_list = [] + type_parts = [t.strip() for t in types_s.split(",") if t.strip()] + # составной: несколько cfg:* или несколько ссылок в типе + composite = len(refs_list) > 1 or len(type_parts) > 1 + row["is_composite"] = composite + others: list[str] = [] + target_full = f"{row['to_kind']}.{row['to_name']}" + for ref in refs_list: + label = f"{ref.get('kind')}.{ref.get('name')}" + if label != target_full and label not in others: + others.append(label) + for t in type_parts: + # cfg:DocumentRef.X → DocumentRef.X + short = t[4:] if t.startswith("cfg:") else t + if short != target_full and short not in others and "Ref." in short: + others.append(short) + row["other_types"] = others + + return rows, totals def get_object( diff --git a/index_1c.py b/index_1c.py index c077373..1a51dfd 100755 --- a/index_1c.py +++ b/index_1c.py @@ -28,6 +28,7 @@ if str(HERE) not in sys.path: sys.path.insert(0, str(HERE)) from index1c.build import build_project_index, default_workers # noqa: E402 +from index1c import read_version_file # noqa: E402 from index1c.cli import ( # noqa: E402 BaseconfError, add_baseconf_args, @@ -175,6 +176,11 @@ def build_parser() -> argparse.ArgumentParser: "Алиасы baseconf: target→crm3-26, source→crm3-dev" ), ) + p.add_argument( + "--version", + action="version", + version=f"%(prog)s {read_version_file()}", + ) add_tool_args(p) sub = p.add_subparsers(dest="cmd", required=True) diff --git a/query_1c.py b/query_1c.py index ef11b97..5a3825d 100755 --- a/query_1c.py +++ b/query_1c.py @@ -13,6 +13,8 @@ python tools/index/query_1c.py show Document.ЗаказКлиента -b crm3-26 python tools/index/query_1c.py modules \"РВС_Transfer\" -b ws-rhana python tools/index/query_1c.py refs Catalog.Партнеры -b source + python tools/index/query_1c.py object ЗаказКлиента -b target + python tools/index/query_1c.py object Партнер -b crm3-26 --clear python tools/index/query_1c.py module CommonModules/РВС_TransferAPIДокументы """ @@ -39,6 +41,7 @@ from index1c.cli import ( # noqa: E402 resolve_project_root, ) from index1c.config import resolve_object_type # noqa: E402 +from index1c import read_version_file # noqa: E402 from index1c.search import ( # noqa: E402 find_refs_to, get_module, @@ -47,6 +50,11 @@ from index1c.search import ( # noqa: E402 search_modules, search_objects, ) +from index1c.object_view import ( # noqa: E402 + find_structure_matches, + format_object_tree, + load_object_structure, +) def _open_or_exit(root: Path): @@ -95,14 +103,31 @@ def cmd_modules(args: argparse.Namespace) -> int: baseconfs = get_baseconfs_for_query(args, conn, root) except BaseconfError as e: return handle_baseconf_error(e) - rows = search_modules(conn, args.query, configs=baseconfs, limit=args.limit) + names_only = bool(args.names_only) + rows = search_modules( + conn, + args.query, + configs=baseconfs, + limit=args.limit, + names_only=names_only, + ) if not rows: print(format_not_found_modules(args.query, baseconfs=baseconfs, conn=conn)) return 2 for r in rows: - print(f"- [{r['config']}] `{r['rel_path']}` ({r['module_kind']}, owner={r['owner']})") - if r.get("snip"): - print(f" {r['snip']}") + syn = r.get("owner_synonym") or "" + if names_only: + # только имя модуля (owner) и синоним объекта-владельца + syn_s = f" — {syn}" if syn else "" + print(f"- [{r['config']}] `{r['owner']}`{syn_s}") + else: + syn_s = f", {syn}" if syn else "" + print( + f"- [{r['config']}] `{r['rel_path']}` " + f"({r['module_kind']}, owner={r['owner']}{syn_s})" + ) + if r.get("snip"): + print(f" {r['snip']}") return 0 finally: conn.close() @@ -116,15 +141,41 @@ def cmd_refs(args: argparse.Namespace) -> int: baseconfs = get_baseconfs_for_query(args, conn, root) except BaseconfError as e: return handle_baseconf_error(e) - rows = find_refs_to(conn, args.target, configs=baseconfs, limit=args.limit) + rows, totals = find_refs_to( + conn, args.target, configs=baseconfs, limit=args.limit + ) if not rows: scope = f" в {', '.join(baseconfs)}" if baseconfs else "" print(f"Ссылок на «{args.target}» не найдено{scope}.") return 2 + + current_cfg = None for r in rows: + if r["config"] != current_cfg: + current_cfg = r["config"] + total = totals.get(current_cfg, 0) + shown = sum(1 for x in rows if x["config"] == current_cfg) + trunc = f", показано {shown}" if shown < total else "" + print(f"## [{current_cfg}] — всего ссылок: {total}{trunc}") + arrow = f"{r['to_kind']}.{r['to_name']}" + if r.get("is_composite"): + others = r.get("other_types") or [] + if others and len(others) <= 4: + arrow += f" (среди прочего: {', '.join(others)})" + elif others: + arrow += f" (среди прочего: {', '.join(others[:3])}, … ещё {len(others) - 3})" + else: + arrow += " (среди прочего)" + print(f"- `{r['from_full_name']}.{r['from_field']}` → {arrow}") + + if len(totals) > 1: + grand = sum(totals.values()) + shown = len(rows) + print() print( - f"- [{r['config']}] `{r['from_full_name']}.{r['from_field']}` " - f"→ {r['to_kind']}.{r['to_name']}" + f"Итого: {grand} ссылок в {len(totals)} конфигурациях; " + f"выведено {shown} (лимит {args.limit} на каждую). " + f"См. -b / --limit." ) return 0 finally: @@ -149,6 +200,76 @@ def cmd_show(args: argparse.Namespace) -> int: conn.close() +def cmd_object(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + conn = _open_or_exit(root) + try: + try: + baseconfs = get_baseconfs_for_query(args, conn, root) + except BaseconfError as e: + return handle_baseconf_error(e) + otype = resolve_object_type(args.type) if args.type else None + matches = find_structure_matches( + conn, + args.query, + configs=baseconfs, + object_type=otype, + limit=args.limit, + ) + if not matches: + print(format_not_found_object(args.query, baseconfs=baseconfs, conn=conn), file=sys.stderr) + return 2 + + for i, m in enumerate(matches): + if i: + print() + obj = load_object_structure(m) + # подтянуть FillChecking/типы из свежего XML в matched_fields + matched = list(m.get("matched_fields") or []) + if matched and obj.get("fields"): + by_f = { + ( + f.get("tabular_section") or "", + f.get("name") or "", + f.get("kind") or "", + ): f + for f in obj["fields"] + } + for mf in matched: + key = ( + mf.get("tabular_section") or "", + mf.get("name") or "", + mf.get("kind") or "", + ) + src = by_f.get(key) + if src: + mf["fill_checking"] = src.get("fill_checking") or "" + mf["types"] = src.get("types") or mf.get("types") + mf["synonym"] = src.get("synonym") or mf.get("synonym") + mf["refs"] = src.get("refs") or mf.get("refs") + # точное совпадение объекта (score 0) → clear без дерева; + # иначе при найденных реквизитах — clear только по ним + kind = m.get("match_kind") or "object" + if args.clear and matched and m.get("score", 100) > 0: + kind = "field" + elif args.clear and m.get("score", 100) == 0: + kind = "object" + print( + format_object_tree( + obj, + clear=bool(args.clear), + matched_fields=matched, + match_kind=kind, + ) + ) + if len(matches) > 1: + print() + print(f"Найдено объектов: {len(matches)}. Уточните -b / --type / --limit.") + return 0 + finally: + conn.close() + + def cmd_module(args: argparse.Namespace) -> int: root = resolve_project_root(args) conn = _open_or_exit(root) @@ -177,6 +298,11 @@ def build_parser() -> argparse.ArgumentParser: "Файл настроек утилиты — --config (-c): см. tools/index/config.example.json" ), ) + p.add_argument( + "--version", + action="version", + version=f"%(prog)s {read_version_file()}", + ) add_tool_args(p) sub = p.add_subparsers(dest="cmd", required=True) @@ -191,12 +317,23 @@ def build_parser() -> argparse.ArgumentParser: m.add_argument("query") add_baseconf_args(m) m.add_argument("--limit", type=int, default=30) + m.add_argument( + "--names-only", + "-n", + action="store_true", + help="Только имена модулей (owner) и синонимы, без пути и фрагментов кода", + ) m.set_defaults(func=cmd_modules) ref = sub.add_parser("refs", help="Обратный индекс: кто ссылается на объект") ref.add_argument("target", help="Catalog.Партнеры | CatalogRef.Партнеры | Партнеры") add_baseconf_args(ref) - ref.add_argument("--limit", type=int, default=100) + ref.add_argument( + "--limit", + type=int, + default=100, + help="Макс. строк на каждую конфигурацию 1С (по умолчанию 100)", + ) ref.set_defaults(func=cmd_refs) sh = sub.add_parser("show", help="Карточка объекта (JSON: поля, типы, ссылки)") @@ -204,6 +341,24 @@ def build_parser() -> argparse.ArgumentParser: add_baseconf_args(sh) sh.set_defaults(func=cmd_show) + ob = sub.add_parser( + "object", + help="Структура объекта: дерево реквизитов, обязательность, типы", + ) + ob.add_argument("query", help="Имя/синоним объекта или реквизита") + add_baseconf_args(ob) + ob.add_argument("--type", help="Ограничить типом: Document, Catalog, …") + ob.add_argument("--limit", type=int, default=10, help="Макс. число объектов") + ob.add_argument( + "--clear", + action="store_true", + help=( + "Краткий вывод: только найденные реквизиты; " + "если совпал сам объект — только его имя без дерева" + ), + ) + ob.set_defaults(func=cmd_object) + md = sub.add_parser("module", help="Метаданные модуля по пути или owner") md.add_argument("path", help="CommonModules/… или CommonModule.Имя") add_baseconf_args(md)