@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,55 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """Dump one metadata object's parse tree so its attribute layout can be read off."""
4
+ from __future__ import print_function
5
+
6
+ import codecs
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ import meta
12
+
13
+ OUT = meta.OUT
14
+
15
+
16
+ def find(name):
17
+ with codecs.open(os.path.join(OUT, "objects.json"), "r", "utf-8") as f:
18
+ for row in json.load(f):
19
+ if row.get("name") == name:
20
+ return row["guid"]
21
+ return None
22
+
23
+
24
+ def shape(node, depth, max_depth, path):
25
+ pad = " " * depth
26
+ if isinstance(node, list):
27
+ strings = [x for x in node if isinstance(x, str) and x and not meta.GUID.match(x)
28
+ and not x.isdigit()]
29
+ hint = (" <- " + ", ".join(repr(s)[:34] for s in strings[:4])) if strings else ""
30
+ print("%s%s list[%d]%s" % (pad, path, len(node), hint))
31
+ if depth >= max_depth:
32
+ return
33
+ for i, item in enumerate(node):
34
+ if isinstance(item, list):
35
+ shape(item, depth + 1, max_depth, "[%d]" % i)
36
+ else:
37
+ if node:
38
+ print("%s%s = %s" % (pad, path, repr(node)[:70]))
39
+
40
+
41
+ if __name__ == "__main__":
42
+ name = sys.argv[1]
43
+ depth = int(sys.argv[2]) if len(sys.argv) > 2 else 4
44
+ guid = find(name)
45
+ print("%s -> %s\n" % (name, guid))
46
+ node = meta.parse(meta.load(guid))
47
+ label = "root"
48
+ if len(sys.argv) > 3: # drill into e.g. 6.2
49
+ for part in sys.argv[3].split("."):
50
+ node = node[int(part)]
51
+ label = "root." + sys.argv[3]
52
+ if "--json" in sys.argv:
53
+ print(json.dumps(node, ensure_ascii=False, indent=1))
54
+ else:
55
+ shape(node, 0, depth, label)
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Pull the external reports and processings an infobase keeps in a catalog out to files.
5
+
6
+ УТП-based configurations store a user's `.erf` / `.epf` in `Справочник.ВнешниеОбработки`,
7
+ attribute `ХранилищеВнешнейОбработки` - a ValueStorage. The value is not compressed:
8
+
9
+ 01 01 | u32 header length | u32 0 | the header, UTF-8: {"#",<ДвоичныеДанные guid>,{1,{0}}}
10
+ u32 0 | u32 data length | u32 0 | the file itself
11
+
12
+ and the file is the same paged container `unpack.py` reads for the configuration, its
13
+ entries nested a level deeper:
14
+
15
+ root {2,<object guid>,}
16
+ <object guid> the object's metadata - its name, forms, templates
17
+ <guid>.0 a container: `info` + `text` is the object module,
18
+ `form` + `module` a form
19
+ <form or template> metadata naming it; a template's body is its `.0`, not a container
20
+ <guid>.1 help, base64 HTML - skipped
21
+
22
+ python tools/1c.py external # every row, to out/external/<code>/
23
+ python tools/1c.py external --kind Отчет # one kind of processing only
24
+ python tools/1c.py external --catalog <Name> # another catalog of the same shape
25
+
26
+ Each row gets `info.json` (code, name, kind, the object's name, its forms and templates)
27
+ and the texts: `ObjectModule.bsl`, `Form.<name>.bsl`, `Form.<name>.form`, `Template.<name>.*`.
28
+ """
29
+ from __future__ import print_function
30
+
31
+ import codecs
32
+ import json
33
+ import os
34
+ import re
35
+ import shutil
36
+ import struct
37
+ import sys
38
+
39
+ import attrs
40
+ import meta
41
+ import migrate
42
+ import paths
43
+ import store
44
+ import unpack
45
+
46
+ CONTAINER = b"\xff\xff\xff\x7f"
47
+
48
+
49
+ def stored_file(blob):
50
+ """The file a ValueStorage holding ДвоичныеДанные carries, or None."""
51
+ blob = bytes(blob or b"")
52
+ if len(blob) < 10 or blob[:2] != b"\x01\x01":
53
+ return None
54
+ (head,) = struct.unpack_from("<I", blob, 2)
55
+ at = 10 + head
56
+ if len(blob) < at + 12:
57
+ return None
58
+ (size,) = struct.unpack_from("<I", blob, at + 4)
59
+ return blob[at + 12:at + 12 + size]
60
+
61
+
62
+ IDENTITY = re.compile(r'\{\d+,\s*\d+,\s*[0-9a-f-]{36}\},\s*"((?:[^"]|"")*)",\s*'
63
+ r'(?:\{\d+,\s*"[a-z]{2}",\s*"((?:[^"]|"")*)")?')
64
+
65
+
66
+ def name_of(text):
67
+ """(name, synonym) off the first identity block - `{1,0,<guid>},"Name",{1,"ru","..."}`.
68
+ Its guid is the object's own, not the one `root` names."""
69
+ m = IDENTITY.search(text or "")
70
+ if not m:
71
+ return None, None
72
+ return m.group(1).replace('""', '"'), (m.group(2) or "").replace('""', '"') or None
73
+
74
+
75
+ def read(file_bytes):
76
+ """{'object': name, 'synonym', 'module': text, 'forms': {name: (form, module)},
77
+ 'templates': {name: bytes}} for one external report or processing."""
78
+ top = dict(unpack.entries(file_bytes))
79
+ texts = dict((k, unpack.as_text(v)) for k, v in top.items() if v[:4] != CONTAINER)
80
+ root = re.search(r"\{2,\s*([0-9a-f-]{36})", texts.get("root") or "")
81
+ object_guid = root.group(1) if root else None
82
+ name, synonym = name_of(texts.get(object_guid))
83
+ out = {"object": name, "synonym": synonym,
84
+ "module": None, "forms": {}, "templates": {}}
85
+ for key, body in sorted(top.items()):
86
+ if not key.endswith(".0"):
87
+ continue
88
+ guid = key[:-2]
89
+ if body[:4] == CONTAINER:
90
+ inner = dict(unpack.entries(body))
91
+ if "form" in inner:
92
+ form = name_of(texts.get(guid))[0] or guid
93
+ out["forms"][form] = (unpack.as_text(inner["form"]), unpack.as_text(inner.get("module") or b""))
94
+ elif "text" in inner:
95
+ out["module"] = unpack.as_text(inner["text"])
96
+ elif guid in texts:
97
+ out["templates"][name_of(texts[guid])[0] or guid] = body
98
+ return out
99
+
100
+
101
+ def template_files(name, body):
102
+ """[(file name, bytes)]. A composition schema is a short binary header and then several XML
103
+ documents back to back - the schema, its settings, ... - each its own file."""
104
+ starts = [m.start() for m in re.finditer(b"(?:\\xef\\xbb\\xbf)?<\\?xml", body)]
105
+ if starts:
106
+ parts = [body[a:b] for a, b in zip(starts, starts[1:] + [len(body)])]
107
+ return [("Template.%s%s.xml" % (name, "" if i == 0 else ".%d" % i), part)
108
+ for i, part in enumerate(parts)]
109
+ text = unpack.as_text(body)
110
+ if text:
111
+ return [("Template.%s.txt" % name, text.encode("utf-8"))]
112
+ return [("Template.%s.bin" % name, body)]
113
+
114
+
115
+ def safe(name):
116
+ return re.sub(r'[\\/:*?"<>|]', "_", name or "")
117
+
118
+
119
+ def write(folder, row, parsed):
120
+ if os.path.isdir(folder):
121
+ shutil.rmtree(folder)
122
+ os.makedirs(folder)
123
+ files = []
124
+
125
+ def put(file_name, data):
126
+ with open(os.path.join(folder, file_name), "wb") as f:
127
+ f.write(data if isinstance(data, bytes) else data.encode("utf-8"))
128
+ files.append(file_name)
129
+
130
+ if parsed["module"]:
131
+ put("ObjectModule.bsl", parsed["module"])
132
+ for form, (layout, module) in sorted(parsed["forms"].items()):
133
+ if layout:
134
+ put("Form.%s.form" % safe(form), layout)
135
+ if module:
136
+ put("Form.%s.bsl" % safe(form), module)
137
+ for template, body in sorted(parsed["templates"].items()):
138
+ for file_name, data in template_files(safe(template), body):
139
+ put(file_name, data)
140
+ info = dict(row, object=parsed["object"], synonym=parsed["synonym"],
141
+ forms=sorted(parsed["forms"]), templates=sorted(parsed["templates"]), files=files)
142
+ with codecs.open(os.path.join(folder, "info.json"), "w", "utf-8") as f:
143
+ json.dump(info, f, ensure_ascii=False, indent="\t")
144
+ return info
145
+
146
+
147
+ def physical(objects, index, name):
148
+ """(table, {attribute: column}) for a catalog, resolved through DBNames."""
149
+ guid, fields = attrs.fields_of(name, objects, attrs.load_json("typemap.json"))
150
+ if not guid or guid not in index:
151
+ raise SystemExit("no catalog %s in this configuration" % name)
152
+ columns = {}
153
+ for field in fields or []:
154
+ entry = index.get(field.get("guid"))
155
+ if entry and not field.get("section"):
156
+ columns[field["name"]] = entry["physical"]
157
+ return index[guid]["physical"], columns
158
+
159
+
160
+ def main():
161
+ args = sys.argv[1:]
162
+ catalog = args[args.index("--catalog") + 1] if "--catalog" in args else u"ВнешниеОбработки"
163
+ only = args[args.index("--kind") + 1] if "--kind" in args else None
164
+
165
+ objects = attrs.load_json("objects.json")
166
+ index = meta.dbnames_index()
167
+ table, columns = physical(objects, index, catalog)
168
+ storage, kind = columns[u"ХранилищеВнешнейОбработки"], columns[u"ВидОбработки"]
169
+
170
+ enum_guid = next(o["guid"] for o in objects
171
+ if o.get("family") == "Enum" and o.get("name") == u"ВидыДополнительныхВнешнихОбработок")
172
+ enum_table = index[enum_guid]["physical"]
173
+ kinds = attrs.enum_values(meta.parse(meta.load(enum_guid)), u"ВидыДополнительныхВнешнихОбработок")
174
+
175
+ cur = migrate.source_connect().cursor()
176
+ cur.execute("SELECT t._Code, t._Description, t._Marked, e._EnumOrder, t.%s FROM %s t "
177
+ "LEFT JOIN %s e ON e._IDRRef = t.%sRRef WHERE t._Folder = %s"
178
+ % (storage, table, enum_table, kind, store.literal(store.TRUE)))
179
+ root = os.path.join(paths.OUT, "external")
180
+ index_rows, skipped = [], []
181
+ for code, name, marked, order, blob in cur.fetchall():
182
+ kind_name = kinds[int(order)] if order is not None and int(order) < len(kinds) else None
183
+ if only and kind_name != only:
184
+ continue
185
+ row = {"code": code.strip(), "name": name.strip(), "kind": kind_name, "marked": marked == b"\x01"}
186
+ data = stored_file(blob)
187
+ if not data or data[:4] != CONTAINER:
188
+ skipped.append(row)
189
+ continue
190
+ info = write(os.path.join(root, row["code"]), row, read(data))
191
+ index_rows.append(dict((k, info[k]) for k in ("code", "name", "kind", "marked", "object", "files")))
192
+
193
+ index_rows.sort(key=lambda r: r["code"])
194
+ with codecs.open(os.path.join(root, "index.json"), "w", "utf-8") as f:
195
+ json.dump(index_rows, f, ensure_ascii=False, indent="\t")
196
+ print("%d written to %s, %d without a file" % (len(index_rows), root, len(skipped)))
197
+ for row in skipped:
198
+ print(" no file: %s %s" % (row["code"], row["name"]))
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Extract every 1C module text in the configuration to files, once.
5
+
6
+ Unpacking on demand costs a container parse per question; this walks every container in
7
+ out/config and writes the module sources to out/code, so from then on the whole business
8
+ logic of the infobase is greppable with ordinary tools.
9
+
10
+ It iterates the container FILES rather than the metadata tree on purpose: form modules
11
+ are their own Config entries and are not reachable from the object list, so a tree walk
12
+ would silently miss them.
13
+
14
+ Layout:
15
+
16
+ out/code/<Family>/<ObjectName>/<container>.<entry>.bsl
17
+ out/code/_unowned/<guid>/... container whose owner is not a top-level object
18
+ out/code/index.json one record per file, for attribution and sizing
19
+
20
+ python tools/extract_code.py # everything
21
+ python tools/extract_code.py --stats # re-print the summary from index.json
22
+ """
23
+ from __future__ import print_function
24
+
25
+ import codecs
26
+ import json
27
+ import os
28
+ import re
29
+ import sys
30
+
31
+ import attrs
32
+ import meta
33
+ import unpack
34
+
35
+ OUT = meta.OUT
36
+ CONFIG = meta.CONFIG
37
+ CODE = os.path.join(OUT, "code")
38
+
39
+ GUID_PREFIX = re.compile(r"^([0-9a-fA-F-]{36})")
40
+ # a module, as opposed to a form layout or a picture, reads as 1C source
41
+ CODE_MARKERS = ("Процедура", "Функция", "Перем ", "Procedure", "Function")
42
+ SAFE = re.compile(r"[^0-9A-Za-zА-Яа-яЁёІіЇїЄєҐґ._-]")
43
+
44
+
45
+ def safe(name):
46
+ return SAFE.sub("_", name)[:80] or "unnamed"
47
+
48
+
49
+ # DBNames names only the classes that own a table. Two more are named here because the
50
+ # evidence is conclusive: 631b75a0's members are ABCАнализПокупателей /
51
+ # АнализДвиженияДенежныхСредств, and 07ee8426's containers hold `form` + `module` rather
52
+ # than the `info` + `text` every module-bearing object has.
53
+ #
54
+ # The rest keep their class GUID as a directory name on purpose. Two of them carry a lot
55
+ # of code - bf845118 (187 members) and 0fe48980 (128) - and both look like common modules
56
+ # from their container shape, which cannot both be true. Guessing would put wrong labels
57
+ # on 500k lines, so out/code/classes.md records the evidence instead and anyone who knows
58
+ # 1C can name them in a minute. Attribution to the owning OBJECT is exact either way.
59
+ CLASS_LABELS = {
60
+ "631b75a0-29e2-11d6-a3c7-0050bae0a776": "Report",
61
+ "07ee8426-87f1-11d5-b99c-0050bae0a95d": "Form",
62
+ }
63
+
64
+
65
+ def owner_index():
66
+ """guid -> (label, object name) for every metadata object in the root.
67
+
68
+ objects_all.json is used rather than objects.json: the latter only covers classes
69
+ that own a physical table, which leaves out common modules, reports and event
70
+ subscriptions - most of the configuration's code.
71
+ """
72
+ index = {}
73
+ for obj in attrs.load_json("objects_all.json"):
74
+ if not obj.get("name"):
75
+ continue
76
+ label = (obj.get("family")
77
+ or CLASS_LABELS.get(obj["classGuid"])
78
+ or "class_" + obj["classGuid"][:8])
79
+ index[obj["guid"]] = (label, obj["name"])
80
+ return index
81
+
82
+
83
+ def is_code(text):
84
+ return text is not None and any(marker in text for marker in CODE_MARKERS)
85
+
86
+
87
+ def nested_owner_index(unknown, owners):
88
+ """Attribute nested containers - form modules, mostly - to the object they belong to.
89
+
90
+ A form is its own Config entry with its own GUID and is not in the root object list,
91
+ but that GUID is written into its owner's definition, so one pass over the root
92
+ objects' files resolves them.
93
+ """
94
+ unknown = set(unknown)
95
+ resolved = {}
96
+ for guid, (label, name) in owners.items():
97
+ text = meta.load(guid)
98
+ if text is None:
99
+ continue
100
+ for found in set(re.findall(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
101
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", text)):
102
+ if found in unknown and found not in resolved:
103
+ resolved[found] = (label, name)
104
+ return resolved
105
+
106
+
107
+ def main():
108
+ if "--stats" in sys.argv:
109
+ report(attrs.load_json(os.path.join("code", "index.json")))
110
+ return
111
+
112
+ owners = owner_index()
113
+ files = sorted(f for f in os.listdir(CONFIG) if f.endswith(".bin"))
114
+
115
+ unknown = set()
116
+ for filename in files:
117
+ match = GUID_PREFIX.match(filename)
118
+ if match and match.group(1) not in owners:
119
+ unknown.add(match.group(1))
120
+ sys.stderr.write("[i] resolving %d nested containers against %d objects\n"
121
+ % (len(unknown), len(owners)))
122
+ nested = nested_owner_index(unknown, owners)
123
+ sys.stderr.write("[i] %d of them attributed\n" % len(nested))
124
+
125
+ records = []
126
+ containers = skipped = 0
127
+ written = set()
128
+
129
+ for i, filename in enumerate(files):
130
+ path = os.path.join(CONFIG, filename)
131
+ with open(path, "rb") as f:
132
+ blob = f.read()
133
+ if blob[:4] != b"\xff\xff\xff\x7f":
134
+ skipped += 1
135
+ continue
136
+ try:
137
+ items = unpack.entries(blob)
138
+ except Exception as exc: # a malformed container is data, not a crash
139
+ records.append({"file": filename, "error": str(exc)[:120]})
140
+ continue
141
+ containers += 1
142
+
143
+ base = filename[:-len(".bin")]
144
+ match = GUID_PREFIX.match(base)
145
+ guid = match.group(1) if match else ""
146
+ family, name = owners.get(guid) or nested.get(guid) or ("_unowned", guid or base)
147
+ target = os.path.join(CODE, safe(family), safe(name))
148
+
149
+ for entry_name, content in items:
150
+ text = unpack.as_text(content)
151
+ if not is_code(text):
152
+ continue
153
+ if not os.path.isdir(target):
154
+ os.makedirs(target)
155
+ # The container's own identity has to reach the filename. Naming the file
156
+ # after the suffix alone - the `0` of `<guid>.0` - gave every container of
157
+ # an object the same name, so an object owning several (a document and its
158
+ # forms, a report and its variants) kept only whichever was written last.
159
+ # That silently lost 825,327 lines of 2.36 M, and the index went on
160
+ # describing files that no longer held what it said.
161
+ stem = "%s.%s" % (guid[:8] or "x", base.split(".", 1)[-1])
162
+ out_name = "%s.%s.bsl" % (safe(stem), safe(entry_name))
163
+ if os.path.join(target, out_name) in written:
164
+ # Eight hex digits are not a promise. Spend the whole GUID rather
165
+ # than lose a file to the same bug in a smaller form.
166
+ out_name = "%s.%s.bsl" % (safe(base), safe(entry_name))
167
+ written.add(os.path.join(target, out_name))
168
+ with codecs.open(os.path.join(target, out_name), "w", "utf-8") as f:
169
+ f.write(text)
170
+ records.append({
171
+ "family": family, "object": name, "guid": match.group(1) if match else None,
172
+ "container": base,
173
+ "entry": entry_name, "path": os.path.relpath(
174
+ os.path.join(target, out_name), OUT).replace("\\", "/"),
175
+ "lines": sum(1 for line in text.splitlines()
176
+ if line.strip() and not line.strip().startswith("//")),
177
+ })
178
+ if i and i % 500 == 0:
179
+ sys.stderr.write("[i] %d/%d files, %d modules\n" % (i, len(files), len(records)))
180
+
181
+ with codecs.open(os.path.join(CODE, "index.json"), "w", "utf-8") as f:
182
+ json.dump(records, f, ensure_ascii=False, indent="\t")
183
+ write_classes(records)
184
+ print("containers=%d non-container files skipped=%d" % (containers, skipped))
185
+ report(records)
186
+
187
+
188
+ def write_classes(records):
189
+ """What each metadata class turned out to hold, so the unlabelled ones can be named."""
190
+ import collections
191
+ objects = attrs.load_json("objects_all.json")
192
+ by_guid = {o["guid"]: o for o in objects}
193
+
194
+ # join on the owner GUID, never on the name: names repeat across classes
195
+ # (ABCКлассификацияПокупателей is a Document, an InfoRg and a Report)
196
+ stats = collections.defaultdict(lambda: {"modules": 0, "lines": 0, "objects": set()})
197
+ for r in records:
198
+ if "lines" not in r:
199
+ continue
200
+ owner = by_guid.get(r.get("guid"))
201
+ key = owner["classGuid"] if owner else "(not a root object - forms, etc.)"
202
+ stats[key]["modules"] += 1
203
+ stats[key]["lines"] += r["lines"]
204
+ stats[key]["objects"].add(r.get("guid"))
205
+
206
+ members = collections.defaultdict(list)
207
+ for o in objects:
208
+ if o.get("name"):
209
+ members[o["classGuid"]].append(o["name"])
210
+
211
+ lines = ["# Metadata classes, by what they actually contain", "",
212
+ "Generated by `tools/extract_code.py`. Classes without a DBNames family own",
213
+ "no table, so they have to be identified from their contents.", "",
214
+ "| class GUID | label | members | with code | modules | lines | sample members |",
215
+ "|---|---|---:|---:|---:|---:|---|"]
216
+ for class_guid, names in sorted(members.items(), key=lambda kv: -len(kv[1])):
217
+ s = stats.get(class_guid, {"modules": 0, "lines": 0, "objects": set()})
218
+ label = CLASS_LABELS.get(class_guid, "")
219
+ lines.append("| `%s` | %s | %d | %d | %d | %d | %s |"
220
+ % (class_guid, label, len(names), len(s["objects"]),
221
+ s["modules"], s["lines"], ", ".join(names[:4])))
222
+ unowned = stats.get("(not a root object - forms, etc.)")
223
+ if unowned:
224
+ lines += ["", "Modules whose container is not a root object (nested forms and the"
225
+ " like): %d modules, %d lines."
226
+ % (unowned["modules"], unowned["lines"])]
227
+ with codecs.open(os.path.join(CODE, "classes.md"), "w", "utf-8") as f:
228
+ f.write("\n".join(lines) + "\n")
229
+
230
+
231
+ def report(records):
232
+ import collections
233
+ by_family = collections.Counter()
234
+ lines_by_family = collections.Counter()
235
+ for r in records:
236
+ if "lines" not in r:
237
+ continue
238
+ by_family[r["family"]] += 1
239
+ lines_by_family[r["family"]] += r["lines"]
240
+ print("\n%-16s %8s %10s" % ("family", "modules", "lines"))
241
+ print("-" * 36)
242
+ for family, count in by_family.most_common():
243
+ print("%-16s %8d %10d" % (family, count, lines_by_family[family]))
244
+ print("-" * 36)
245
+ print("%-16s %8d %10d" % ("total", sum(by_family.values()),
246
+ sum(lines_by_family.values())))
247
+ print("\n-> %s" % CODE)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ main()