@dforge-core/dforge-mcp 0.2.45 → 0.2.47

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -1
  3. package/dist/server.js +71 -3
  4. package/package.json +3 -3
  5. package/resources/docs/dsl-reference.md +1 -1
  6. package/resources/schemas/data-views.schema.json +50 -0
  7. package/scripts/install-skills.mjs +1 -0
  8. package/skills/dforge-1c-import/SKILL.md +375 -0
  9. package/skills/dforge-1c-import/references/environment.md +98 -0
  10. package/skills/dforge-1c-import/references/loading.md +164 -0
  11. package/skills/dforge-1c-import/references/measurements.md +210 -0
  12. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  13. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  14. package/skills/dforge-1c-import/scripts/README.md +164 -0
  15. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  16. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  17. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  18. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  19. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  20. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  21. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  22. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  23. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  24. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  25. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  26. package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
  27. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  28. package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
  29. package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
  30. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  31. package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  33. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  34. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  35. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  36. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  37. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  38. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  39. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  40. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  41. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  42. package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
  43. package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
  44. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  45. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  46. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  47. package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
  48. package/skills/dforge-1c-import/scripts/lib/peek.py +68 -0
  49. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  50. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  51. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  52. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  53. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  54. package/skills/dforge-1c-import/scripts/lib/recorders.py +91 -0
  55. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  56. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  57. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  58. package/skills/dforge-1c-import/scripts/lib/seedstate.py +493 -0
  59. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  60. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  61. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  62. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  63. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  64. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  65. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  66. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  67. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  68. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  69. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  70. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  71. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  72. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  73. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  74. package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
  75. package/skills/dforge-mcp-author/SKILL.md +5 -0
  76. package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
  77. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  78. package/skills/dforge-mcp-author/references/formulas.md +32 -1
  79. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  80. package/skills/dforge-mcp-author/references/queries.md +17 -2
  81. package/skills/dforge-mcp-author/references/registers.md +1 -0
  82. package/skills/dforge-mcp-author/references/reports.md +1 -1
  83. package/skills/dforge-mcp-author/references/validation-checklist.md +1 -0
@@ -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)