@dforge-core/dforge-mcp 0.2.44 → 0.2.46-next
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +3 -2
- package/dist/server.js +109 -84
- package/package.json +48 -46
- package/resources/docs/conventions.md +17 -14
- package/resources/docs/dsl-reference.md +15 -5
- package/resources/schemas/entity.schema.json +1 -1
- package/resources/schemas/registers.schema.json +8 -3
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +374 -0
- package/skills/dforge-1c-import/references/environment.md +97 -0
- package/skills/dforge-1c-import/references/loading.md +161 -0
- package/skills/dforge-1c-import/references/measurements.md +210 -0
- package/skills/dforge-1c-import/references/print-forms.md +148 -0
- package/skills/dforge-1c-import/scripts/1c.py +178 -0
- package/skills/dforge-1c-import/scripts/README.md +110 -0
- package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
- package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
- package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
- package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
- package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
- package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
- package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
- package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
- package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
- package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
- package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
- package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
- package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
- package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
- package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
- package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
- package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
- package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
- package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
- package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
- package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
- package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
- package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
- package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
- package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
- package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
- package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
- package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
- package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
- package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
- package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
- package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
- package/skills/dforge-mcp-author/SKILL.md +8 -2
- package/skills/dforge-mcp-author/examples/column-security/entities/product.json +1 -1
- package/skills/dforge-mcp-author/examples/column-security/manifest.json +0 -1
- package/skills/dforge-mcp-author/examples/column-security/security/roles.json +2 -2
- package/skills/dforge-mcp-author/references/action-dsl.md +20 -7
- package/skills/dforge-mcp-author/references/conventions.md +7 -1
- package/skills/dforge-mcp-author/references/field-types.md +3 -2
- package/skills/dforge-mcp-author/references/formulas.md +5 -1
- package/skills/dforge-mcp-author/references/print-templates.md +5 -2
- package/skills/dforge-mcp-author/references/queries.md +1 -1
- package/skills/dforge-mcp-author/references/registers.md +18 -3
- package/skills/dforge-mcp-author/references/reports.md +4 -1
- package/skills/dforge-mcp-author/references/schema-import.md +1 -1
- package/skills/dforge-mcp-author/references/security.md +1 -1
- package/skills/dforge-mcp-author/references/translations.md +38 -15
- package/skills/dforge-mcp-author/references/validation-checklist.md +2 -3
- package/skills/dforge-module-build/SKILL.md +10 -1
- package/skills/dforge-module-ship/SKILL.md +31 -3
- package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +0 -35
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Every register in the infobase, with the facts a declaration needs.
|
|
5
|
+
|
|
6
|
+
python tools/1c.py inventory # the client's registers, as a table
|
|
7
|
+
python tools/1c.py inventory --all # the vendor's too
|
|
8
|
+
python tools/1c.py inventory --json # out/registers.json, for other tools
|
|
9
|
+
python tools/1c.py inventory --name _AccumRg17011 # one register, in full
|
|
10
|
+
python tools/1c.py inventory --name мсбТараВЦехах # the same, by 1C name
|
|
11
|
+
|
|
12
|
+
Written after the same survey was done three times in throwaway scripts, each with the
|
|
13
|
+
register names typed in by hand. That is the wrong shape twice over: the names belong to
|
|
14
|
+
the configuration rather than to a script, and a script that names them cannot notice a
|
|
15
|
+
register nobody has thought of yet.
|
|
16
|
+
|
|
17
|
+
**What is derivable and what is not.** Everything here is a FACT read from the extracted
|
|
18
|
+
metadata: which registers exist, their tables and fields, how many rows, whether 1C keeps
|
|
19
|
+
a record kind or a recorder, which ported entity holds them and under what column names.
|
|
20
|
+
None of it is a DECISION - that a register is in scope, what KIND it becomes in dForge,
|
|
21
|
+
which document binds to it with which direction. Those live in `tools/project/registers.py`
|
|
22
|
+
and should key off the names printed here rather than restate them.
|
|
23
|
+
|
|
24
|
+
The two are easy to confuse because 1C's own family looks like a decision and is not:
|
|
25
|
+
`мсбФактическаяРаботаЛиний` is an information register that the client writes signed values
|
|
26
|
+
into and sums, so the faithful port is `turnover`. The family is evidence, not an answer,
|
|
27
|
+
which is exactly why this tool reports evidence and stops.
|
|
28
|
+
|
|
29
|
+
**Writers are reported from the handler text, and that source is weak in two ways.** 1C
|
|
30
|
+
names a movement set two ways - `Движения.<имя>` and `Движения["<имя>"]` - and a scan that
|
|
31
|
+
knows one of them reads as a clean result while missing whole registers; eight of these are
|
|
32
|
+
written through the bracket form alone, so both are counted here. Worse, a register is also
|
|
33
|
+
written from COMMON modules, which no scan of document handlers can reach at all:
|
|
34
|
+
`мсбПродажиНалоговыйУчет` holds 478,260 rows from eight document types and its own handlers
|
|
35
|
+
name it nowhere.
|
|
36
|
+
|
|
37
|
+
So the writer column is evidence, never a census, and an empty one reads `ask data` rather
|
|
38
|
+
than `none`. The authoritative answer is in the rows: `python tools/1c.py recorders <table>`
|
|
39
|
+
reads the recorder type ids and cannot miss a syntax or a common module.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import print_function
|
|
42
|
+
|
|
43
|
+
import codecs
|
|
44
|
+
import collections
|
|
45
|
+
import io
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import sys
|
|
50
|
+
|
|
51
|
+
import binding
|
|
52
|
+
import paths
|
|
53
|
+
|
|
54
|
+
OUT = paths.OUT
|
|
55
|
+
|
|
56
|
+
# The two ways a handler names a movement set, and the marker of a handler that consumes
|
|
57
|
+
# from the register's own balance rather than writing what its lines say.
|
|
58
|
+
DOT = u"Движения."
|
|
59
|
+
BRACKET = u"Движения["
|
|
60
|
+
CONSUME = u"ВыполнитьРасход"
|
|
61
|
+
CLIENT = binding.client_prefixes()
|
|
62
|
+
|
|
63
|
+
# THE SECOND WAY A HANDLER CONSUMES, and the one that hid a 31,166-row writer.
|
|
64
|
+
# `ВыполнитьРасход` is one spelling: ask the register to take from its balance. The other is
|
|
65
|
+
# to JOIN the register's own Остатки inside the posting handler and write movements built
|
|
66
|
+
# from what came back - `мсбВыпускБлюд` takes Рецептура and Цена off the balance that way,
|
|
67
|
+
# then writes an expense with `ВыполнитьДвижения`. Same algorithm, no marker, and the sweep
|
|
68
|
+
# called that register bindable.
|
|
69
|
+
#
|
|
70
|
+
# WHERE the read sits decides, which is why this is not a plain substring test: a fill
|
|
71
|
+
# procedure reading the balance is a user pressing "Заполнить" and says nothing about
|
|
72
|
+
# posting. `мсбПланИзготовленияБлюд` reads the same balance in `ЗаполнитьОстатки()` and
|
|
73
|
+
# posts only what its lines carry, so it is NOT algorithmic - and a test that ignored the
|
|
74
|
+
# procedure would have said it was.
|
|
75
|
+
POSTING = u"ОбработкаПроведения"
|
|
76
|
+
OWN_READ = (u"РегистрНакопления\\.%s\\.(?:ОстаткиИОбороты|Остатки|Обороты)")
|
|
77
|
+
PROCEDURE = re.compile(u"(?m)^[ ]*(?:Процедура|Функция)[ ]+([А-Яа-яA-Za-z0-9_]+)")
|
|
78
|
+
|
|
79
|
+
FAMILIES = ("AccumRg", "InfoRg")
|
|
80
|
+
|
|
81
|
+
# How a query asks a register for an answer. 1C spells the virtual tables by suffixing the
|
|
82
|
+
# register's name, so counting them says what the configuration actually WANTS from it -
|
|
83
|
+
# which is the evidence that decides whether a port models it as a balance or a turnover.
|
|
84
|
+
# Longest first: ОстаткиИОбороты contains both of the others.
|
|
85
|
+
READS = [
|
|
86
|
+
(u"ОстаткиИОбороты", "both"),
|
|
87
|
+
(u"Остатки", "remains"),
|
|
88
|
+
(u"Обороты", "turnover"),
|
|
89
|
+
(u"СрезПоследних", "slice"),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
# A read that names no virtual table at all. "РегистрНакопления.<name>" with nothing appended is
|
|
93
|
+
# the register's own table, which is how a turnover register kept as a fact table gets read -
|
|
94
|
+
# and the virtual-table count says nothing at all about that. "Движения.<name>" is a WRITE,
|
|
95
|
+
# which is why the prefix is part of the needle rather than the bare name.
|
|
96
|
+
DIRECT = [u"РегистрНакопления.", u"РегистрСведений."]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _mentions(text, name):
|
|
100
|
+
"""Does the text name this register, as opposed to one whose name starts the same?
|
|
101
|
+
|
|
102
|
+
`мсбПродажиНалоговыйУчет` is a prefix of `мсбПродажиНалоговыйУчетВременный`, so a plain
|
|
103
|
+
substring search hands the shorter register the longer one's writers - which it did:
|
|
104
|
+
`КорректировкаДолга` was reported as writing the first when it writes the second. 1C
|
|
105
|
+
identifiers run on letters, digits and underscore, so the character after the name has
|
|
106
|
+
to be none of those. Cyrillic counts as alphanumeric here, which is exactly what makes
|
|
107
|
+
the check work.
|
|
108
|
+
"""
|
|
109
|
+
at = text.find(name)
|
|
110
|
+
while at >= 0:
|
|
111
|
+
after = text[at + len(name):at + len(name) + 1]
|
|
112
|
+
if not (after.isalnum() or after == "_"):
|
|
113
|
+
return True
|
|
114
|
+
at = text.find(name, at + 1)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _kind(family, record_kind):
|
|
119
|
+
"""What the infobase's own shape says this register is.
|
|
120
|
+
|
|
121
|
+
`_RecordKind` tells a balance register from a turnover one **among accumulation
|
|
122
|
+
registers**. An information register never has one, so reading its absence the same way
|
|
123
|
+
labelled all 56 of the client's information registers "turnover" and offered that as
|
|
124
|
+
modelling guidance. The family comes first.
|
|
125
|
+
"""
|
|
126
|
+
if family != "AccumRg":
|
|
127
|
+
return "info"
|
|
128
|
+
return "bal" if record_kind else "turn"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _every_module():
|
|
132
|
+
"""Object -> the text of every module in the configuration, not only documents.
|
|
133
|
+
|
|
134
|
+
A register is READ from anywhere - reports, common modules, other documents - so the
|
|
135
|
+
question "what does the configuration want from this register" cannot be asked of
|
|
136
|
+
document handlers alone.
|
|
137
|
+
"""
|
|
138
|
+
index_path = os.path.join(OUT, "code", "index.json")
|
|
139
|
+
if not os.path.isfile(index_path):
|
|
140
|
+
return {}
|
|
141
|
+
texts = collections.OrderedDict()
|
|
142
|
+
with io.open(index_path, encoding="utf-8") as f:
|
|
143
|
+
index = json.load(f)
|
|
144
|
+
for entry in index:
|
|
145
|
+
path = os.path.join(OUT, entry["path"])
|
|
146
|
+
if not os.path.isfile(path):
|
|
147
|
+
continue
|
|
148
|
+
with io.open(path, encoding="utf-8", errors="ignore") as f:
|
|
149
|
+
texts.setdefault(entry["object"], []).append(f.read())
|
|
150
|
+
return dict((name, u"".join(parts)) for name, parts in texts.items())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _reads(modules, name):
|
|
154
|
+
"""How the configuration asks this register for an answer, and who asks.
|
|
155
|
+
|
|
156
|
+
"ОстаткиИОбороты" CONTAINS both "Остатки" and "Обороты", so its occurrences are
|
|
157
|
+
subtracted from theirs rather than stopping the scan. Stopping at the first suffix that
|
|
158
|
+
matched threw away every other suffix the same module used, and a module that asks for
|
|
159
|
+
the balance in one query and the turnover in another is the ordinary case: it is the
|
|
160
|
+
difference between "this register is read as a balance" and "read as both".
|
|
161
|
+
"""
|
|
162
|
+
counts = collections.OrderedDict((label, 0) for _, label in READS)
|
|
163
|
+
counts["direct"] = 0
|
|
164
|
+
askers = {}
|
|
165
|
+
for obj, text in modules.items():
|
|
166
|
+
big = READS[0][0]
|
|
167
|
+
both = text.count(name + big)
|
|
168
|
+
for suffix, label in READS:
|
|
169
|
+
found = text.count(name + suffix)
|
|
170
|
+
if label in ("remains", "turnover"):
|
|
171
|
+
found -= both
|
|
172
|
+
if found <= 0:
|
|
173
|
+
continue
|
|
174
|
+
counts[label] += found
|
|
175
|
+
askers.setdefault(label, set()).add(obj)
|
|
176
|
+
bare = sum(text.count(prefix + name) for prefix in DIRECT)
|
|
177
|
+
qualified = sum(text.count(prefix + name + suffix)
|
|
178
|
+
for prefix in DIRECT for suffix, _ in READS)
|
|
179
|
+
# The long suffix was counted three times in `qualified`: once as itself and once
|
|
180
|
+
# inside each of the two shorter suffixes it contains.
|
|
181
|
+
qualified -= 2 * sum(text.count(prefix + name + big) for prefix in DIRECT)
|
|
182
|
+
if bare - qualified > 0:
|
|
183
|
+
counts["direct"] += bare - qualified
|
|
184
|
+
askers.setdefault("direct", set()).add(obj)
|
|
185
|
+
return counts, dict((k, sorted(v)) for k, v in askers.items())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _handlers():
|
|
189
|
+
"""Document -> the text of every module it has, from the extracted code."""
|
|
190
|
+
index_path = os.path.join(OUT, "code", "index.json")
|
|
191
|
+
if not os.path.isfile(index_path):
|
|
192
|
+
return {}
|
|
193
|
+
with io.open(index_path, encoding="utf-8") as f:
|
|
194
|
+
index = json.load(f)
|
|
195
|
+
texts = collections.OrderedDict()
|
|
196
|
+
for entry in index:
|
|
197
|
+
if entry.get("family") != "Document":
|
|
198
|
+
continue
|
|
199
|
+
path = os.path.join(OUT, entry["path"])
|
|
200
|
+
if not os.path.isfile(path):
|
|
201
|
+
continue
|
|
202
|
+
with io.open(path, encoding="utf-8", errors="ignore") as f:
|
|
203
|
+
texts.setdefault(entry["object"], []).append(f.read())
|
|
204
|
+
return dict((name, u"".join(parts)) for name, parts in texts.items())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _ported():
|
|
208
|
+
"""Physical table -> (module.entity, {column: source}), from the column map."""
|
|
209
|
+
import columnmap
|
|
210
|
+
|
|
211
|
+
generated, mapped = columnmap.build()
|
|
212
|
+
out = {}
|
|
213
|
+
for module in mapped:
|
|
214
|
+
for entity in mapped[module]:
|
|
215
|
+
found = mapped[module][entity]
|
|
216
|
+
if not isinstance(found, dict):
|
|
217
|
+
continue
|
|
218
|
+
table = found.get("table")
|
|
219
|
+
if table:
|
|
220
|
+
out[table] = ("%s.%s" % (module, entity), found.get("columns") or {})
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _consumes_in_posting(text, name):
|
|
225
|
+
"""The procedure where this document's POSTING path reads the register's own balance.
|
|
226
|
+
|
|
227
|
+
The procedures the posting handler can reach are itself plus everything it names - one
|
|
228
|
+
hop, which is as far as this needs to go: a handler that builds its movements out of the
|
|
229
|
+
balance does the reading where it writes, or in a procedure it calls with the movement
|
|
230
|
+
table in hand.
|
|
231
|
+
|
|
232
|
+
Returns that procedure's name, or None. Naming it matters: "this is an algorithm" is
|
|
233
|
+
worth no more than the line someone can go and look at.
|
|
234
|
+
"""
|
|
235
|
+
bounds = [(m.group(1), m.start()) for m in PROCEDURE.finditer(text)]
|
|
236
|
+
if not bounds:
|
|
237
|
+
return None
|
|
238
|
+
ends = [start for _, start in bounds[1:]] + [len(text)]
|
|
239
|
+
bodies = [(proc, text[start:end]) for (proc, start), end in zip(bounds, ends)]
|
|
240
|
+
|
|
241
|
+
reach = set()
|
|
242
|
+
for proc, body in bodies:
|
|
243
|
+
if proc == POSTING:
|
|
244
|
+
reach.add(proc)
|
|
245
|
+
reach.update(other for other, _ in bodies if other != proc and other in body)
|
|
246
|
+
|
|
247
|
+
asked = re.compile(OWN_READ % re.escape(name))
|
|
248
|
+
for proc, body in bodies:
|
|
249
|
+
if proc in reach and asked.search(body):
|
|
250
|
+
return proc
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def collect(everything=False):
|
|
255
|
+
"""One record per register: what it is, where it landed, and who writes it."""
|
|
256
|
+
import attrs
|
|
257
|
+
import autospec
|
|
258
|
+
|
|
259
|
+
generated, model = autospec.build()
|
|
260
|
+
ported = _ported()
|
|
261
|
+
handlers = _handlers()
|
|
262
|
+
modules = _every_module()
|
|
263
|
+
|
|
264
|
+
rows_by_table = {}
|
|
265
|
+
report_path = os.path.join(OUT, "report.json")
|
|
266
|
+
if os.path.isfile(report_path):
|
|
267
|
+
with codecs.open(report_path, "r", "utf-8") as f:
|
|
268
|
+
for row in json.load(f):
|
|
269
|
+
if row.get("physical"):
|
|
270
|
+
rows_by_table[row["physical"]] = row.get("rows")
|
|
271
|
+
|
|
272
|
+
found = []
|
|
273
|
+
for obj in model.objects:
|
|
274
|
+
family = obj.get("family")
|
|
275
|
+
if family not in FAMILIES:
|
|
276
|
+
continue
|
|
277
|
+
name = obj.get("name") or ""
|
|
278
|
+
mine = name.startswith(CLIENT)
|
|
279
|
+
if not (everything or mine):
|
|
280
|
+
continue
|
|
281
|
+
table, _ = model.physical(name, family)
|
|
282
|
+
columns = model.table_columns(table) if table else set()
|
|
283
|
+
|
|
284
|
+
fields = []
|
|
285
|
+
for field in model.fields(name, family):
|
|
286
|
+
physical = model.fld.get(field.get("guid"))
|
|
287
|
+
fields.append({
|
|
288
|
+
"name": field.get("name"),
|
|
289
|
+
"type": field.get("type"),
|
|
290
|
+
"column": physical,
|
|
291
|
+
# Which of the three a field is, as 1C declared it. This is the one thing
|
|
292
|
+
# the declaration cannot be guessed from: a register's KEY is its
|
|
293
|
+
# dimensions and what it SUMS is its resources, and reading the two off
|
|
294
|
+
# the column types gets it wrong as soon as a resource is a quantity and
|
|
295
|
+
# a dimension is a number. 1C says so outright, in the collection the
|
|
296
|
+
# field sits in, and the inventory was throwing that away.
|
|
297
|
+
"role": attrs.ROLE.get(field.get("collection"), "?"),
|
|
298
|
+
# 1C splits a reference that may point at several catalogues into
|
|
299
|
+
# _RRRef/_RTRef/_TYPE. dForge references are single-target, so this is
|
|
300
|
+
# a modelling problem and worth carrying as a fact.
|
|
301
|
+
"composite": bool(physical and (physical + "_TYPE") in columns),
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
writers = []
|
|
305
|
+
for document, text in handlers.items():
|
|
306
|
+
# The bracket form carries its own boundary - the closing quote - so only the
|
|
307
|
+
# dotted one needs the identifier check.
|
|
308
|
+
dot = _mentions(text, DOT + name)
|
|
309
|
+
bracket = (BRACKET + '"' + name + '"') in text
|
|
310
|
+
if not (dot or bracket):
|
|
311
|
+
continue
|
|
312
|
+
near = []
|
|
313
|
+
lines = text.splitlines()
|
|
314
|
+
for i, line in enumerate(lines):
|
|
315
|
+
if _mentions(line, name):
|
|
316
|
+
near.extend(lines[max(0, i - 25):i + 25])
|
|
317
|
+
writers.append({
|
|
318
|
+
"document": document,
|
|
319
|
+
"client": document.startswith(CLIENT),
|
|
320
|
+
"how": ("dot" if dot else "") + ("+bracket" if dot and bracket
|
|
321
|
+
else "bracket" if bracket else ""),
|
|
322
|
+
# A handler that reads the balance and takes from it is an algorithm, and
|
|
323
|
+
# no declarative binding expresses one. Two spellings, both counted -
|
|
324
|
+
# see the note on POSTING for why the second one needs the procedure.
|
|
325
|
+
"consumes": (CONSUME in u"\n".join(near)
|
|
326
|
+
or bool(_consumes_in_posting(text, name))),
|
|
327
|
+
"consumesIn": _consumes_in_posting(text, name),
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
read_counts, read_by = _reads(modules, name)
|
|
331
|
+
entity, mapped_columns = ported.get(table, (None, {}))
|
|
332
|
+
found.append({
|
|
333
|
+
"reads": read_counts,
|
|
334
|
+
"readBy": read_by,
|
|
335
|
+
"name": name,
|
|
336
|
+
"family": family,
|
|
337
|
+
"table": table,
|
|
338
|
+
"rows": rows_by_table.get(table),
|
|
339
|
+
"client": mine,
|
|
340
|
+
"recordKind": "_RecordKind" in columns,
|
|
341
|
+
"recorder": "_RecorderRRef" in columns,
|
|
342
|
+
"period": "_Period" in columns,
|
|
343
|
+
"entity": entity,
|
|
344
|
+
"columns": mapped_columns,
|
|
345
|
+
"fields": fields,
|
|
346
|
+
"writers": sorted(writers, key=lambda w: w["document"]),
|
|
347
|
+
})
|
|
348
|
+
return sorted(found, key=lambda r: -(r["rows"] or 0))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def declared():
|
|
352
|
+
"""Every register the port has declared: the entity it holds -> its register code.
|
|
353
|
+
|
|
354
|
+
Read from the GENERATED packages (`modules/<code>/registers.json`) rather than from a
|
|
355
|
+
project's overlay. A first attempt went through `binding.registers()` and reported two
|
|
356
|
+
declared registers as missing, because that overlay only carries the registers of
|
|
357
|
+
GENERATED modules - an authored module declares its own inline, and `shipping` has two.
|
|
358
|
+
The package is where both end up, and it is also what actually gets installed, so it is
|
|
359
|
+
the one place that cannot disagree with the tenant.
|
|
360
|
+
"""
|
|
361
|
+
out = {}
|
|
362
|
+
if not os.path.isdir(paths.MODULES):
|
|
363
|
+
return out
|
|
364
|
+
for module in sorted(os.listdir(paths.MODULES)):
|
|
365
|
+
path = os.path.join(paths.MODULES, module, "registers.json")
|
|
366
|
+
if not os.path.isfile(path):
|
|
367
|
+
continue
|
|
368
|
+
with io.open(path, encoding="utf-8") as f:
|
|
369
|
+
try:
|
|
370
|
+
declarations = json.load(f)
|
|
371
|
+
except ValueError:
|
|
372
|
+
continue
|
|
373
|
+
for code, definition in (declarations or {}).items():
|
|
374
|
+
if not isinstance(definition, dict):
|
|
375
|
+
continue
|
|
376
|
+
held = definition.get("journal") or definition.get("entity")
|
|
377
|
+
if held:
|
|
378
|
+
out["%s.%s" % (module, held)] = "%s.%s" % (module, code)
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def undeclared(records):
|
|
383
|
+
"""Registers a document writes that the port has not declared, worst first.
|
|
384
|
+
|
|
385
|
+
This is the reason the inventory exists. A register nobody has thought of cannot be
|
|
386
|
+
remembered, and a list typed into a script cannot grow. The configuration knows; ask it.
|
|
387
|
+
|
|
388
|
+
**A register with no ported entity belongs here too.** The first version skipped them,
|
|
389
|
+
on the reasoning that a declaration needs an entity to be declared over - which is true
|
|
390
|
+
and is not a reason to hide them: not being ported at all is a bigger gap than not being
|
|
391
|
+
declared, not a smaller one. Four registers and 460,813 rows were missing from the
|
|
392
|
+
report because of it. They are listed with the entity shown as `unmapped`.
|
|
393
|
+
|
|
394
|
+
**Nor does a register qualify by having a writer we can SEE.** The writer list comes
|
|
395
|
+
from document handler text, and 1C also writes registers from common modules, which no
|
|
396
|
+
scan of documents can reach: `мсбПродажиНалоговыйУчет` holds 478,260 rows written by
|
|
397
|
+
eight document types - the data says so through their recorder ids - and its own
|
|
398
|
+
handlers name it nowhere. Gating on the text would have dropped it from the report
|
|
399
|
+
entirely. So what qualifies is holding ROWS: something wrote them, whether or not we
|
|
400
|
+
can point at it.
|
|
401
|
+
"""
|
|
402
|
+
held = declared()
|
|
403
|
+
out = []
|
|
404
|
+
for record in records:
|
|
405
|
+
if not (record["writers"] or record["rows"]):
|
|
406
|
+
continue
|
|
407
|
+
if record["entity"] and record["entity"] in held:
|
|
408
|
+
continue
|
|
409
|
+
out.append(record)
|
|
410
|
+
return out
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _table(records):
|
|
414
|
+
print("%-46s %-7s %9s %6s %7s %7s %s"
|
|
415
|
+
% ("register", "family", "rows", "kind", "writers", "consume", "ported entity"))
|
|
416
|
+
print("-" * 118)
|
|
417
|
+
for record in records:
|
|
418
|
+
writers = record["writers"]
|
|
419
|
+
print("%-46s %-7s %9s %6s %7s %7s %s"
|
|
420
|
+
% (record["name"][:46], record["family"],
|
|
421
|
+
record["rows"] if record["rows"] is not None else "?",
|
|
422
|
+
_kind(record["family"], record["recordKind"]),
|
|
423
|
+
"%d/%d" % (sum(1 for w in writers if w["client"]), len(writers)),
|
|
424
|
+
sum(1 for w in writers if w["consumes"]) or "-",
|
|
425
|
+
record["entity"] or "-"))
|
|
426
|
+
written = [r for r in records if r["writers"]]
|
|
427
|
+
algorithmic = [r for r in written if any(w["consumes"] for w in r["writers"])]
|
|
428
|
+
print("-" * 118)
|
|
429
|
+
print("%d registers, %d written by a document, %d of those algorithmic; %s rows"
|
|
430
|
+
% (len(records), len(written), len(algorithmic),
|
|
431
|
+
sum(r["rows"] or 0 for r in records)))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _detail(record):
|
|
435
|
+
print("%s %s %s" % (record["name"], record["family"], record["table"]))
|
|
436
|
+
shape = {
|
|
437
|
+
"bal": "a record kind, so a balance register",
|
|
438
|
+
"turn": "no record kind, so a turnover register",
|
|
439
|
+
"info": "an information register - a record kind would not apply",
|
|
440
|
+
}[_kind(record["family"], record["recordKind"])]
|
|
441
|
+
print(" rows %s, %s, %s, %s"
|
|
442
|
+
% (record["rows"], shape,
|
|
443
|
+
"written by a recorder" if record["recorder"] else "maintained directly",
|
|
444
|
+
"periodic" if record["period"] else "not periodic"))
|
|
445
|
+
print(" ported to %s" % (record["entity"] or "nothing - not ported at all"))
|
|
446
|
+
print("\n fields (dimensions key the register, resources are what it sums):")
|
|
447
|
+
order = {"dimension": 0, "resource": 1, "attr": 2}
|
|
448
|
+
for field in sorted(record["fields"], key=lambda f: order.get(f.get("role"), 3)):
|
|
449
|
+
ported = [c for c, source in (record["columns"] or {}).items()
|
|
450
|
+
if field["column"] and source and field["column"] in source]
|
|
451
|
+
print(" %-10s %-26s %-34s %-12s %s%s"
|
|
452
|
+
% (field.get("role") or "?", field["name"], str(field["type"])[:34],
|
|
453
|
+
field["column"] or "", ported[0] if ported else "",
|
|
454
|
+
" COMPOSITE" if field["composite"] else ""))
|
|
455
|
+
asked = [(label, n) for label, n in record["reads"].items() if n]
|
|
456
|
+
print("\n what the configuration asks it for:")
|
|
457
|
+
if asked:
|
|
458
|
+
for label, n in asked:
|
|
459
|
+
who = record["readBy"].get(label) or []
|
|
460
|
+
print(" %-10s %4d query(ies) %s%s"
|
|
461
|
+
% (label, n, ", ".join(who[:4]), " ..." if len(who) > 4 else ""))
|
|
462
|
+
else:
|
|
463
|
+
print(" nothing - no query names its virtual tables")
|
|
464
|
+
print("\n writers:")
|
|
465
|
+
for writer in record["writers"]:
|
|
466
|
+
print(" %-46s %-8s %-8s %s"
|
|
467
|
+
% (writer["document"][:46], "client" if writer["client"] else "vendor",
|
|
468
|
+
writer["how"], "CONSUMES from the balance" if writer["consumes"] else ""))
|
|
469
|
+
if not record["writers"]:
|
|
470
|
+
print(" (no document writes it)")
|
|
471
|
+
print("\n the data has the last word on writers:")
|
|
472
|
+
print(" python tools/1c.py recorders %s" % record["table"])
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def main():
|
|
476
|
+
args = sys.argv[1:]
|
|
477
|
+
everything = "--all" in args
|
|
478
|
+
as_json = "--json" in args
|
|
479
|
+
name = None
|
|
480
|
+
if "--name" in args:
|
|
481
|
+
name = args[args.index("--name") + 1].decode("utf-8") \
|
|
482
|
+
if sys.version_info[0] == 2 else args[args.index("--name") + 1]
|
|
483
|
+
|
|
484
|
+
records = collect(everything=everything or bool(name))
|
|
485
|
+
if name:
|
|
486
|
+
# By 1C name or by physical table. The table is the ASCII handle: it is what an
|
|
487
|
+
# error, a log line or `recorders` gives you, and it survives a shell that mangles
|
|
488
|
+
# Cyrillic on the command line.
|
|
489
|
+
for record in records:
|
|
490
|
+
if name in (record["name"], record["table"]):
|
|
491
|
+
_detail(record)
|
|
492
|
+
return 0
|
|
493
|
+
print("no register called %s - try --all, or give its _AccumRg/_InfoRg table" % name)
|
|
494
|
+
return 1
|
|
495
|
+
|
|
496
|
+
if "--undeclared" in args:
|
|
497
|
+
left = undeclared(records)
|
|
498
|
+
visible = [r for r in left if r["writers"]]
|
|
499
|
+
print("%d register(s) the port has not declared: they hold rows, or a handler names "
|
|
500
|
+
"them, or both.\n%d have a writer we can see; the rest say `ask data`, which "
|
|
501
|
+
"means\n`recorders <table>` is the one that knows.\n"
|
|
502
|
+
% (len(left), len(visible)))
|
|
503
|
+
print("%-46s %9s %5s %8s %10s %s"
|
|
504
|
+
% ("register", "rows", "kind", "writers", "consuming", "ported entity"))
|
|
505
|
+
print("-" * 106)
|
|
506
|
+
for record in left:
|
|
507
|
+
writers = record["writers"]
|
|
508
|
+
consuming = sum(1 for w in writers if w["consumes"])
|
|
509
|
+
print("%-46s %9s %5s %8s %10s %s"
|
|
510
|
+
% (record["name"][:46], record["rows"],
|
|
511
|
+
_kind(record["family"], record["recordKind"]),
|
|
512
|
+
"%d/%d" % (sum(1 for w in writers if w["client"]), len(writers))
|
|
513
|
+
if writers else "ask data",
|
|
514
|
+
"%d ALGO" % consuming if consuming else "-",
|
|
515
|
+
record["entity"] or "unmapped - not ported at all"))
|
|
516
|
+
print("-" * 106)
|
|
517
|
+
print("%s rows, of which %s behind a handler that consumes from the balance"
|
|
518
|
+
% (sum(r["rows"] or 0 for r in left),
|
|
519
|
+
sum(r["rows"] or 0 for r in left
|
|
520
|
+
if any(w["consumes"] for w in r["writers"]))))
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
if as_json:
|
|
524
|
+
path = paths.derived("registers.json")
|
|
525
|
+
with io.open(path, "w", encoding="utf-8") as f:
|
|
526
|
+
f.write(json.dumps(records, ensure_ascii=False, indent="\t"))
|
|
527
|
+
print("%s: %d register(s)" % (path, len(records)))
|
|
528
|
+
return 0
|
|
529
|
+
|
|
530
|
+
_table(records)
|
|
531
|
+
return 0
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
if __name__ == "__main__":
|
|
535
|
+
sys.exit(main())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
u"""Does a lower layer of modules name anything in the layer installed on top of it?
|
|
4
|
+
|
|
5
|
+
A port of a configuration built on a vendor's comes out as two layers: the vendor's,
|
|
6
|
+
generated from the client's own configuration, and the client's, installed on top and
|
|
7
|
+
depending on it - never the other way round. That is what lets the vendor layer install
|
|
8
|
+
for the next client without this one's, and what lets this client's changes be seen and
|
|
9
|
+
priced on their own (docs/task-vendor-logic.md section 2).
|
|
10
|
+
|
|
11
|
+
A convention does not keep that true; this does. It reads every module of the lower layer
|
|
12
|
+
and reports each place it names a module of the upper one: an entity link, a foreign key,
|
|
13
|
+
a register binding, a declared dependency. Exit status 1 when there is any.
|
|
14
|
+
|
|
15
|
+
python tools/1c.py layers <lower dir> <upper dir> # e.g. modules-utp modules
|
|
16
|
+
|
|
17
|
+
The directories are arguments rather than names in here, so the check carries to the next
|
|
18
|
+
project unchanged.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import print_function
|
|
21
|
+
|
|
22
|
+
import codecs
|
|
23
|
+
import collections
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
import paths
|
|
31
|
+
|
|
32
|
+
QUALIFIED = re.compile(r"^([a-z][a-z0-9_]*)\.([a-z0-9_]+)$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def modules_in(directory):
|
|
36
|
+
"""Module code -> its directory, for every module (a folder holding a manifest)."""
|
|
37
|
+
found = {}
|
|
38
|
+
for name in sorted(os.listdir(directory)):
|
|
39
|
+
manifest = os.path.join(directory, name, "manifest.json")
|
|
40
|
+
if os.path.isfile(manifest):
|
|
41
|
+
with io.open(manifest, encoding="utf-8-sig") as f:
|
|
42
|
+
found[json.load(f).get("code", name)] = os.path.join(directory, name)
|
|
43
|
+
return found
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def crossings(lower, upper):
|
|
47
|
+
"""(lower module, file, json path, named upper module.object) for every crossing."""
|
|
48
|
+
upper_codes = set(modules_in(upper))
|
|
49
|
+
found = []
|
|
50
|
+
for code, folder in sorted(modules_in(lower).items()):
|
|
51
|
+
with io.open(os.path.join(folder, "manifest.json"), encoding="utf-8-sig") as f:
|
|
52
|
+
for dependency in json.load(f).get("dependencies", {}):
|
|
53
|
+
if dependency in upper_codes:
|
|
54
|
+
found.append((code, "manifest.json", "dependencies", dependency))
|
|
55
|
+
for root, _, files in os.walk(folder):
|
|
56
|
+
for name in sorted(files):
|
|
57
|
+
if not name.endswith(".json") or name == "manifest.json":
|
|
58
|
+
continue
|
|
59
|
+
path = os.path.join(root, name)
|
|
60
|
+
with io.open(path, encoding="utf-8-sig") as f:
|
|
61
|
+
try:
|
|
62
|
+
tree = json.load(f)
|
|
63
|
+
except ValueError:
|
|
64
|
+
continue
|
|
65
|
+
relative = os.path.relpath(path, folder).replace(os.sep, "/")
|
|
66
|
+
for where, value in _strings(tree, ()):
|
|
67
|
+
match = QUALIFIED.match(value)
|
|
68
|
+
if match and match.group(1) in upper_codes:
|
|
69
|
+
found.append((code, relative, "/".join(where), value))
|
|
70
|
+
return found
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _strings(node, where):
|
|
74
|
+
if isinstance(node, dict):
|
|
75
|
+
for key, value in node.items():
|
|
76
|
+
for item in _strings(value, where + (key,)):
|
|
77
|
+
yield item
|
|
78
|
+
elif isinstance(node, list):
|
|
79
|
+
for index, value in enumerate(node):
|
|
80
|
+
for item in _strings(value, where + (str(index),)):
|
|
81
|
+
yield item
|
|
82
|
+
elif isinstance(node, type(u"")):
|
|
83
|
+
yield where, node
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main():
|
|
87
|
+
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
|
88
|
+
if len(args) != 2:
|
|
89
|
+
raise SystemExit("usage: layers <lower dir> <upper dir>")
|
|
90
|
+
lower, upper = (os.path.join(paths.ROOT, a) if not os.path.isabs(a) else a for a in args)
|
|
91
|
+
found = crossings(lower, upper)
|
|
92
|
+
out = codecs.getwriter("utf-8")(sys.stdout.buffer)
|
|
93
|
+
by_target = collections.Counter(c[3] for c in found)
|
|
94
|
+
places = collections.defaultdict(set)
|
|
95
|
+
for module, path, where, target in found:
|
|
96
|
+
places[target].add(u"%s/%s" % (module, path))
|
|
97
|
+
for target, count in by_target.most_common():
|
|
98
|
+
out.write(u"%6d %-34s from %d file(s), e.g. %s\n"
|
|
99
|
+
% (count, target, len(places[target]), sorted(places[target])[0]))
|
|
100
|
+
out.write(u"%d reference(s) from %s into %s, %d distinct object(s)\n"
|
|
101
|
+
% (len(found), args[0], args[1], len(by_target)))
|
|
102
|
+
return 1 if found else 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
sys.exit(main() or 0)
|