@dforge-core/dforge-mcp 0.2.45 → 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -1
  3. package/package.json +48 -46
  4. package/scripts/install-skills.mjs +1 -0
  5. package/skills/dforge-1c-import/SKILL.md +374 -0
  6. package/skills/dforge-1c-import/references/environment.md +97 -0
  7. package/skills/dforge-1c-import/references/loading.md +161 -0
  8. package/skills/dforge-1c-import/references/measurements.md +210 -0
  9. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  10. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  11. package/skills/dforge-1c-import/scripts/README.md +110 -0
  12. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  13. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  14. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  15. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  16. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  17. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  18. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  19. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  20. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  21. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  22. package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
  23. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  24. package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
  25. package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
  26. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  27. package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
  28. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  29. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  30. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  31. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  33. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  34. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  35. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  36. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  37. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  38. package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
  39. package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
  40. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  41. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  42. package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
  43. package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
  44. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  45. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  46. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  47. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  48. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  49. package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
  50. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  51. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  52. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  53. package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
  54. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  55. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  56. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  57. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  58. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  59. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  60. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  61. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  62. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  63. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  64. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  65. package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
  66. package/skills/dforge-mcp-author/SKILL.md +5 -0
@@ -0,0 +1,239 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Write a module's ported 1C print forms from one compact spec, and preview them.
3
+
4
+ A print form reads its data from the module's stored procedures (a print template's
5
+ `datasets`, each a procedure keyed by the record): the procedures' signatures and columns go
6
+ in `logic/stored_procedures.json`, the rights to run them in `security/roles.json`, the
7
+ template in `ui/print_templates.json` beside its HTML and CSS under `print_templates/`. This
8
+ writes the first three from one spec into the project's `authored/<module>/` tree, which
9
+ `genmodule` lays over the generated module; the template's HTML and CSS are the project's own
10
+ files there. A procedure's columns are read off its `RETURNS TABLE` in the module's SQL, so the
11
+ declaration cannot drift from the function.
12
+
13
+ `ui/print_templates.json` is written only when the platform takes `datasets` on a template
14
+ (PRINT_DATASETS=1): until then the schema refuses the key, and a template without its data
15
+ would print empty, so the forms wait and the procedures are installed and runnable on their
16
+ own.
17
+
18
+ A spec module (tools/project/prints_<module>.py) defines MODULE, ROLES, SQL (the files that
19
+ declare its procedures) and PRINTS, a list of `print_form(...)`.
20
+
21
+ python tools/lib/printgen.py tools/project/prints_warehouse.py
22
+ python tools/1c.py printgen preview tools/project/prints_warehouse.py <form> <key>
23
+ # render one record with LiquidJS, screenshot it
24
+ """
25
+ from __future__ import print_function
26
+
27
+ import codecs
28
+ import collections
29
+ import decimal
30
+ import imp
31
+ import io
32
+ import json
33
+ import os
34
+ import re
35
+ import subprocess
36
+ import sys
37
+
38
+ O = collections.OrderedDict
39
+ HERE = os.path.dirname(os.path.abspath(__file__))
40
+
41
+ # a procedure column's pg type -> (fieldTypeCd, baseDatatypeCd)
42
+ PG = {"text": ("text", "string"), "integer": ("number", "number"), "numeric": ("number", "number"),
43
+ "boolean": ("checkbox", "bool"), "date": ("date", "date"), "timestamptz": ("datetime", "timestamp"),
44
+ "uuid": ("text", "string")}
45
+
46
+
47
+ def dataset(name, sp, single=False):
48
+ """A template's dataset: its name in the template, the procedure, one row or many."""
49
+ return dict(name=name, sp=sp, single=single)
50
+
51
+
52
+ def print_form(code, entity, en, uk, file, datasets, key, css=None, orientation="portrait",
53
+ size="A4", source=None):
54
+ """A print form on `entity` (the module's own, or `module.entity`), its procedures called
55
+ with the record's `key` column."""
56
+ return dict(code=code, entity=entity, en=en, uk=uk, file=file, css=css, datasets=datasets,
57
+ key=key, orientation=orientation, size=size, source=source)
58
+
59
+
60
+ def _read(path):
61
+ if not os.path.isfile(path):
62
+ return O()
63
+ with codecs.open(path, "r", "utf-8") as f:
64
+ return json.load(f, object_pairs_hook=O)
65
+
66
+
67
+ def _write(path, doc):
68
+ directory = os.path.dirname(path)
69
+ if not os.path.isdir(directory):
70
+ os.makedirs(directory)
71
+ with codecs.open(path, "w", "utf-8") as f:
72
+ f.write(json.dumps(doc, ensure_ascii=False, indent="\t") + "\n")
73
+
74
+
75
+ def signatures(sql_paths):
76
+ """{function name: (params [(name, pg type)], columns [(name, pg type)])} off the SQL."""
77
+ out = {}
78
+ pattern = re.compile(r"CREATE OR REPLACE FUNCTION\s+\w+\.(\w+)\s*\(([^)]*)\)\s*RETURNS TABLE\s*\((.*?)\)\s*LANGUAGE",
79
+ re.S | re.I)
80
+ for path in sql_paths:
81
+ with io.open(path, encoding="utf-8") as f:
82
+ text = f.read()
83
+ for name, params, columns in pattern.findall(text):
84
+ def pairs(spec):
85
+ return [tuple(part.strip().split()[:2]) for part in spec.split(",") if part.strip()]
86
+ out[name] = (pairs(params), pairs(columns))
87
+ return out
88
+
89
+
90
+ def build(spec, root):
91
+ sql = [os.path.join(root, p) for p in spec.SQL]
92
+ sigs = signatures(sql)
93
+ sps = _read(os.path.join(root, "logic", "stored_procedures.json"))
94
+ for k in [k for k in sps if k.startswith("prn_")]:
95
+ del sps[k]
96
+ rights = O()
97
+ templates = O()
98
+ uk = O()
99
+ for form in spec.PRINTS:
100
+ for ds in form["datasets"]:
101
+ if ds["sp"] not in sigs:
102
+ raise SystemExit("printgen: %s is not declared in %s" % (ds["sp"], ", ".join(spec.SQL)))
103
+ params, columns = sigs[ds["sp"]]
104
+ sps[ds["sp"]] = O([
105
+ ("description", "%s: %s" % (form["en"], ds["name"])), ("functionName", ds["sp"]),
106
+ ("params", [O([("paramCd", p), ("pgType", t), ("fieldTypeCd", PG.get(t, PG["text"])[0]),
107
+ ("label", p), ("required", True)]) for p, t in params]),
108
+ ("columns", [O([("columnCd", c), ("label", c), ("fieldTypeCd", PG.get(t, PG["text"])[0]),
109
+ ("baseDatatypeCd", PG.get(t, PG["text"])[1])]) for c, t in columns])])
110
+ rights["sp:" + ds["sp"]] = "E"
111
+ entry = O([("entityCd", form["entity"]), ("description", form["uk"]), ("file", form["file"])])
112
+ if form["css"]:
113
+ entry["css"] = form["css"]
114
+ entry["pageSettings"] = O([("size", form["size"]), ("orientation", form["orientation"]),
115
+ ("margins", O([("top", "8mm"), ("right", "8mm"), ("bottom", "8mm"), ("left", "8mm")]))])
116
+ entry["datasets"] = O((ds["name"], O([("sp", ds["sp"]), ("params", O([(sigs[ds["sp"]][0][0][0], form["key"])])),
117
+ ("single", ds["single"])])) for ds in form["datasets"])
118
+ templates[form["code"]] = entry
119
+ uk[form["code"]] = {"label": form["uk"]}
120
+
121
+ _write(os.path.join(root, "logic", "stored_procedures.json"), sps)
122
+ path = os.path.join(root, "security", "roles.json")
123
+ doc = _read(path)
124
+ for role in spec.ROLES:
125
+ r = doc.setdefault(role, O([("rights", O())]))
126
+ r.setdefault("rights", O())
127
+ for key in [k for k in r["rights"] if k.startswith("sp:prn_")]:
128
+ del r["rights"][key]
129
+ r["rights"].update(rights)
130
+ _write(path, doc)
131
+ path = os.path.join(root, "translations", "uk-UA.json")
132
+ translations = _read(path)
133
+ translations["print_templates"] = uk
134
+ _write(path, translations)
135
+ target = os.path.join(root, "ui", "print_templates.json")
136
+ if os.environ.get("PRINT_DATASETS") == "1":
137
+ _write(target, templates)
138
+ elif os.path.isfile(target):
139
+ os.remove(target)
140
+ return templates
141
+
142
+
143
+ # --------------------------------------------------------------------------- preview
144
+
145
+ RENDER_JS = r"""
146
+ const { Liquid } = require('liquidjs');
147
+ const fs = require('fs');
148
+ const [tpl, css, data, out] = process.argv.slice(2);
149
+ const engine = new Liquid();
150
+ const nf = (v, d) => (v === null || v === undefined || v === '') ? '' :
151
+ Number(v).toLocaleString('uk-UA', { minimumFractionDigits: d || 0, maximumFractionDigits: d || 0 });
152
+ engine.registerFilter('format_number', (v, d) => nf(v, d));
153
+ engine.registerFilter('format_currency', (v, c, d) => nf(v, d === undefined ? 2 : d) + (c ? ' ' + c : ''));
154
+ engine.registerFilter('format_date', (v) => v === null || v === undefined ? '' : String(v));
155
+ const ctx = JSON.parse(fs.readFileSync(data, 'utf8'));
156
+ engine.parseAndRender(fs.readFileSync(tpl, 'utf8'), ctx).then(html => {
157
+ fs.writeFileSync(out, '<!doctype html><html><head><meta charset="utf-8"><style>' +
158
+ (css ? fs.readFileSync(css, 'utf8') : '') + '</style></head><body>' + html + '</body></html>');
159
+ });
160
+ """
161
+
162
+ EDGE = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
163
+
164
+
165
+ def _plain(value):
166
+ if isinstance(value, decimal.Decimal):
167
+ return float(value)
168
+ if hasattr(value, "isoformat"):
169
+ return value.isoformat()
170
+ return value
171
+
172
+
173
+ def preview(spec, root, code, key, out_dir, node_dir):
174
+ """Render one record's form with LiquidJS (the editor's engine) to HTML and a PNG."""
175
+ import migrate
176
+ migrate.load_env()
177
+ form = [f for f in spec.PRINTS if f["code"] == code][0]
178
+ pg = migrate.pg_connect()
179
+ pg.autocommit = True
180
+ cur = pg.cursor()
181
+ # the module's SQL as written, in a transaction rolled back after the read: the functions the
182
+ # installer owns are left as they are
183
+ cur.execute("BEGIN")
184
+ for path in spec.SQL:
185
+ with io.open(os.path.join(root, path), encoding="utf-8") as f:
186
+ cur.execute(f.read())
187
+ ctx = {}
188
+ for ds in form["datasets"]:
189
+ cur.execute("SELECT * FROM %s.%s(%%s)" % (spec.SCHEMA, ds["sp"]), (key,))
190
+ names = [d[0] for d in cur.description]
191
+ rows = [dict((n, _plain(v)) for n, v in zip(names, r)) for r in cur.fetchall()]
192
+ ctx[ds["name"]] = (rows[0] if rows else None) if ds["single"] else rows
193
+ cur.execute("ROLLBACK")
194
+ if not os.path.isdir(out_dir):
195
+ os.makedirs(out_dir)
196
+ data = os.path.join(out_dir, code + ".json")
197
+ with io.open(data, "w", encoding="utf-8") as f:
198
+ f.write(json.dumps(ctx, ensure_ascii=False))
199
+ script = os.path.join(node_dir, "render.js")
200
+ with io.open(script, "w", encoding="utf-8") as f:
201
+ f.write(RENDER_JS)
202
+ html = os.path.join(out_dir, code + ".html")
203
+ tpl = os.path.join(root, "print_templates", form["file"])
204
+ css = os.path.join(root, "print_templates", form["css"]) if form["css"] else ""
205
+ subprocess.check_call(["node", script, tpl, css, data, html], cwd=node_dir)
206
+ png = os.path.join(out_dir, code + ".png")
207
+ width = 1400 if form["orientation"] == "landscape" else 1000
208
+ subprocess.call([EDGE, "--headless=new", "--disable-gpu", "--screenshot=" + png,
209
+ "--window-size=%d,1300" % width, "file:///" + html.replace("\\", "/")],
210
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
211
+ return html, png
212
+
213
+
214
+ def load(spec_path):
215
+ return imp.load_source(os.path.splitext(os.path.basename(spec_path))[0], spec_path)
216
+
217
+
218
+ def main(argv):
219
+ if len(argv) > 1 and argv[1] == "preview":
220
+ spec = load(argv[2])
221
+ root = os.path.normpath(os.path.join(HERE, "..", "project", "authored", spec.MODULE))
222
+ out = os.environ.get("PRINT_PREVIEW_DIR") or os.path.join(HERE, "..", "..", "out", "prints")
223
+ node = os.environ.get("PRINT_NODE_DIR")
224
+ if not node:
225
+ raise SystemExit("printgen preview: set PRINT_NODE_DIR to a folder with liquidjs installed")
226
+ for path in preview(spec, root, argv[3], argv[4], os.path.normpath(out), node):
227
+ print(path)
228
+ return 0
229
+ for spec_path in argv[1:]:
230
+ spec = load(spec_path)
231
+ root = os.path.normpath(os.path.join(HERE, "..", "project", "authored", spec.MODULE))
232
+ templates = build(spec, root)
233
+ print("%s: %d print form(s)%s" % (spec.MODULE, len(templates),
234
+ "" if os.environ.get("PRINT_DATASETS") == "1" else ", templates held until the platform takes datasets"))
235
+ return 0
236
+
237
+
238
+ if __name__ == "__main__":
239
+ sys.exit(main(sys.argv))
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Rebuild a tenant from nothing: install every module, then load every contour.
5
+
6
+ Written the day `docker/reset.sh` wiped the tenant and the recipe turned out to live in
7
+ one person's head and one section of a status document. Both are worse than a command:
8
+ the order is computed rather than remembered, each step says what it did, and a failure
9
+ stops at the module that failed instead of leaving half a tenant behind.
10
+
11
+ python tools/1c.py rebuild --code <tenant> # install, then load, everything
12
+ python tools/1c.py rebuild --code <tenant> --install # modules only
13
+ python tools/1c.py rebuild --code <tenant> --load # data only
14
+ python tools/1c.py rebuild --code <tenant> --from production # resume at a module
15
+ python tools/1c.py rebuild --code <tenant> --only planning # one module, on its own
16
+ python tools/1c.py rebuild --code <tenant> --plan # print the order and stop
17
+
18
+ **Install-then-load is the wrong order for a register that caches its totals.** A `totals`
19
+ cache is rebuilt when the register is installed or its shape changes, and never again - there
20
+ is no rebuild command - so rows loaded afterwards never reach it and `getBalance` as of a date
21
+ comes back short with no error at all. None of the registers declared here has a `totals`
22
+ cache, which is the only reason this order is safe; the day one does, its contour has to be
23
+ loaded before the module that declares it is installed.
24
+
25
+ It does NOT create the tenant. That needs an admin user to exist first and is one
26
+ command in the platform's own CLI, which says plainly what it wants:
27
+
28
+ curl -s -X POST http://localhost:5002/api/auth/register -H "Content-Type: application/json" \\
29
+ -d '{"email":"...","password":"...","firstName":"...","lastName":"..."}'
30
+ npx -y @dforge-core/dforge-cli@latest tenant create \\
31
+ --code <code> --name <name> --admin <email>
32
+
33
+ Installing runs the platform's published CLI on this machine (tools/lib/cli.py), straight
34
+ from this repository's module directories.
35
+ """
36
+ from __future__ import print_function
37
+
38
+ import argparse
39
+ import os
40
+ import subprocess
41
+ import sys
42
+ import time
43
+
44
+ import cli
45
+ import deps
46
+ import paths
47
+
48
+ def install_order():
49
+ """Every generated module, in an order where nothing precedes what it depends on."""
50
+ sequence, stuck = deps.order(deps.graph())
51
+ if stuck:
52
+ raise SystemExit("rebuild: %s cannot be ordered - a cycle in the manifests"
53
+ % ", ".join(sorted(stuck)))
54
+ return [code for code in sequence
55
+ if os.path.isdir(os.path.join(paths.MODULES, code))]
56
+
57
+
58
+ def remote(tenant):
59
+ """`--url` and `--token` for an install through the running API, when DFORGE_URL is set.
60
+
61
+ A local install writes the tenant's metadata straight to the database, and the running
62
+ dforge-api never hears of it: it goes on serving the old action scripts and register
63
+ bindings until its cache expires or it restarts - two engine replays on 2026-10-19 ran
64
+ the previous code. Through the API the same request invalidates them. A new tenant has
65
+ no session to sign in with yet, so this stays opt-in.
66
+ """
67
+ url = os.environ.get("DFORGE_URL")
68
+ if not url:
69
+ return []
70
+ import posting
71
+ named = os.environ.get("DF_TENANT")
72
+ os.environ["DF_TENANT"] = tenant
73
+ try:
74
+ return ["--url", url, "--token", posting.token()]
75
+ finally:
76
+ if named is None:
77
+ del os.environ["DF_TENANT"]
78
+ else:
79
+ os.environ["DF_TENANT"] = named
80
+
81
+
82
+ def install(tenant, code, verbose=False):
83
+ rc, out = cli.run(["module", "install", "--code", tenant,
84
+ "--path", os.path.join(paths.MODULES, code), "--force"] + remote(tenant))
85
+ if verbose:
86
+ print(out)
87
+ # a local install reports in prose, one through the API in JSON
88
+ if rc != 0 or not ("installed successfully" in out or '"success":true' in out):
89
+ tail = [line for line in out.splitlines() if line.strip()][-12:]
90
+ raise SystemExit("rebuild: installing %s failed\n %s"
91
+ % (code, "\n ".join(tail)))
92
+ # The one line worth keeping from a hundred: what the registrar actually did.
93
+ return next((l.strip() for l in out.splitlines() if "Registers:" in l), "")
94
+
95
+
96
+ def load(tenant, code, verbose=False):
97
+ """Copy the contour's rows. `migrate` reads the tenant's own shape, so it must run
98
+ after the install rather than beside it.
99
+
100
+ **The tenant has to be handed over explicitly.** `migrate` picks its target from
101
+ `DF_TENANT`, which has no default, and it takes no tenant argument - so a `load` that
102
+ only passed the module loaded whatever `.env` pointed at while reporting the tenant
103
+ named on the command line. It truncates as it goes, so the wrong tenant is not a
104
+ no-op: `rebuild --code <other> --load` emptied and reloaded whatever DF_TENANT named and left the other empty.
105
+ """
106
+ command = [sys.executable, os.path.join(paths.ROOT, "tools", "1c.py"),
107
+ "migrate", "run", code, "--truncate"]
108
+ # Both names, and both pinned rather than cleared. `migrate.load_env()` fills from `.env`
109
+ # with `setdefault`, so an unset variable is not a safe default - it is an invitation:
110
+ # clearing DF_PG_DB would let a DF_PG_DB in `.env` win over the DF_TENANT set here, and
111
+ # the load would truncate that database while reporting this tenant.
112
+ env = dict(os.environ, PYTHONIOENCODING="utf-8",
113
+ DF_TENANT=tenant, DF_PG_DB="dforge_%s" % tenant)
114
+ proc = subprocess.Popen(command, cwd=paths.ROOT, env=env,
115
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
116
+ out = proc.communicate()[0].decode("utf-8", "replace")
117
+ if verbose:
118
+ print(out)
119
+ if proc.returncode != 0:
120
+ tail = [line for line in out.splitlines() if line.strip()][-12:]
121
+ raise SystemExit("rebuild: loading %s failed\n %s" % (code, "\n ".join(tail)))
122
+ # A balance register whose journal was just reloaded has a state nothing else rebuilds:
123
+ # 1C has no state table to copy, and the platform only moves it by posting.
124
+ seeded = subprocess.Popen(
125
+ [sys.executable, os.path.join(paths.ROOT, "tools", "1c.py"), "seedstate", "run", code],
126
+ cwd=paths.ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
127
+ seed_out = seeded.communicate()[0].decode("utf-8", "replace")
128
+ if verbose:
129
+ print(seed_out)
130
+ if seeded.returncode != 0:
131
+ raise SystemExit("rebuild: seeding the balance state of %s failed\n %s"
132
+ % (code, seed_out.strip()))
133
+ for line in reversed(out.splitlines()):
134
+ parts = line.split()
135
+ if len(parts) >= 2 and parts[1] == "entities":
136
+ return line.strip()
137
+ return ""
138
+
139
+
140
+ def run(codes, doing, action, verbose=False):
141
+ started = time.time()
142
+ for index, code in enumerate(codes, 1):
143
+ mark = time.time()
144
+ note = action(code, verbose)
145
+ print(" %2d/%d %-12s %6.1fs %s"
146
+ % (index, len(codes), code, time.time() - mark, note))
147
+ sys.stdout.flush()
148
+ print(" %s: %d module(s) in %.0fs" % (doing, len(codes), time.time() - started))
149
+
150
+
151
+ def main():
152
+ parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
153
+ parser.add_argument("--code", required=True, help="tenant code, as the platform knows it")
154
+ parser.add_argument("--install", action="store_true", help="modules only")
155
+ parser.add_argument("--load", action="store_true", help="data only")
156
+ parser.add_argument("--from", dest="start", help="resume at this module")
157
+ parser.add_argument("--only", help="this module alone, dependencies assumed installed")
158
+ parser.add_argument("--plan", action="store_true", help="print the order and stop")
159
+ parser.add_argument("--verbose", action="store_true")
160
+ args = parser.parse_args()
161
+
162
+ codes = install_order()
163
+ if args.only and args.start:
164
+ raise SystemExit("rebuild: --only and --from ask for different things")
165
+ if args.only:
166
+ # One module, after a declaration changed. Its dependencies are already installed -
167
+ # reinstalling them would be an upgrade in place, which is safe but not free, and
168
+ # reloading their rows would be minutes of nothing.
169
+ if args.only not in codes:
170
+ raise SystemExit("rebuild: %r is not one of %s" % (args.only, ", ".join(codes)))
171
+ codes = [args.only]
172
+ if args.start:
173
+ if args.start not in codes:
174
+ raise SystemExit("rebuild: %r is not one of %s" % (args.start, ", ".join(codes)))
175
+ codes = codes[codes.index(args.start):]
176
+
177
+ print("[i] tenant %s, modules from %s" % (args.code, paths.MODULES))
178
+ print("[i] %d module(s): %s" % (len(codes), " -> ".join(codes)))
179
+ if args.plan:
180
+ return
181
+
182
+ both = not (args.install or args.load)
183
+ if args.install or both:
184
+ print("\ninstalling")
185
+ run(codes, "installed",
186
+ lambda code, verbose: install(args.code, code, verbose), args.verbose)
187
+ if args.load or both:
188
+ print("\nloading into %s" % args.code)
189
+ run(codes, "loaded",
190
+ lambda code, verbose: load(args.code, code, verbose), args.verbose)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ u"""What each document type actually wrote: register rows by recorder type, every register.
4
+
5
+ The call graph (`callgraph.py`) says what the code CAN write. This says what 1C DID write,
6
+ and the difference between the two is the call graph's error, measured rather than argued:
7
+
8
+ * a register the code reaches but no document of that type ever wrote is either a branch
9
+ this client never took or an over-reach of the analysis - the chain in callgraph.json
10
+ tells which;
11
+ * a register a document wrote that the code does not reach is a miss, always a defect.
12
+
13
+ A register with several recorder types carries `_RecorderTRef`, whose low two bytes are the
14
+ recorder's document table number. One with a single recorder type has no TRef; its type is
15
+ the one document table whose keys its recorders match.
16
+
17
+ python tools/1c.py recorded # writes out/recorded.json
18
+ python tools/1c.py recorded --compare # and scores out/callgraph.json against it
19
+
20
+ Read-only, and `NOLOCK`: a count does not need to wait for anyone.
21
+ """
22
+ from __future__ import print_function
23
+
24
+ import codecs
25
+ import collections
26
+ import io
27
+ import json
28
+ import os
29
+ import sys
30
+
31
+ import meta
32
+ import migrate
33
+ import paths
34
+
35
+ TARGET = os.path.join(paths.OUT, "recorded.json")
36
+
37
+
38
+ def registers():
39
+ """(family, name, physical table) for every register in the configuration."""
40
+ with io.open(os.path.join(paths.OUT, "objects.json"), encoding="utf-8") as f:
41
+ objects = json.load(f)
42
+ index = meta.dbnames_index()
43
+ found = []
44
+ for obj in objects:
45
+ if obj.get("family") not in ("AccumRg", "InfoRg", "AccRg", "CRg"):
46
+ continue
47
+ entry = index.get(obj["guid"])
48
+ if entry and entry["type"] == obj["family"]:
49
+ found.append((obj["family"], obj["name"], entry["physical"]))
50
+ return sorted(found)
51
+
52
+
53
+ def documents():
54
+ """Document table number -> (name, physical table)."""
55
+ with io.open(os.path.join(paths.OUT, "objects.json"), encoding="utf-8") as f:
56
+ objects = json.load(f)
57
+ index = meta.dbnames_index()
58
+ found = {}
59
+ for obj in objects:
60
+ if obj.get("family") != "Document":
61
+ continue
62
+ entry = index.get(obj["guid"])
63
+ if entry and entry["type"] == "Document":
64
+ found[int(entry["number"])] = (obj["name"], entry["physical"])
65
+ return found
66
+
67
+
68
+ def columns(cur, table):
69
+ cur.execute("SELECT name FROM sys.columns WHERE object_id = OBJECT_ID(?)", "dbo." + table)
70
+ return set(row[0] for row in cur.fetchall())
71
+
72
+
73
+ def single_recorder(cur, table, docs):
74
+ """The one document type a TRef-less register's recorders belong to."""
75
+ cur.execute("SELECT DISTINCT TOP (20) _RecorderRRef FROM dbo.[%s] WITH (NOLOCK)" % table)
76
+ refs = [bytes(r[0]) for r in cur.fetchall()]
77
+ if not refs:
78
+ return None
79
+ marks = ",".join("?" * len(refs))
80
+ for number, (name, physical) in sorted(docs.items()):
81
+ cur.execute("SELECT COUNT(*) FROM dbo.[%s] WHERE _IDRRef IN (%s)" % (physical, marks),
82
+ *refs)
83
+ if cur.fetchone()[0]:
84
+ return number
85
+ return None
86
+
87
+
88
+ def measure():
89
+ migrate.load_env()
90
+ cur = migrate.mssql_connect().cursor()
91
+ docs = documents()
92
+ result = collections.defaultdict(dict) # document -> register -> rows
93
+ unmatched = []
94
+ for family, name, table in registers():
95
+ cols = columns(cur, table)
96
+ if "_RecorderRRef" not in cols:
97
+ continue # not subordinate to a recorder
98
+ if "_RecorderTRef" in cols:
99
+ cur.execute("SELECT _RecorderTRef, COUNT_BIG(*) FROM dbo.[%s] WITH (NOLOCK)"
100
+ " GROUP BY _RecorderTRef" % table)
101
+ for tref, rows in cur.fetchall():
102
+ number = int.from_bytes(bytes(tref), "big") & 0xFFFF
103
+ if number in docs:
104
+ result[docs[number][0]][name] = int(rows)
105
+ else:
106
+ unmatched.append((name, number, int(rows)))
107
+ else:
108
+ cur.execute("SELECT COUNT_BIG(*) FROM dbo.[%s] WITH (NOLOCK)" % table)
109
+ rows = int(cur.fetchone()[0])
110
+ if not rows:
111
+ continue
112
+ number = single_recorder(cur, table, docs)
113
+ if number is None:
114
+ unmatched.append((name, None, rows))
115
+ else:
116
+ result[docs[number][0]][name] = rows
117
+ sys.stderr.write(".")
118
+ sys.stderr.write("\n")
119
+ return dict(result), unmatched
120
+
121
+
122
+ def compare(recorded, out):
123
+ """Score the call graph against what 1C wrote, per document that wrote anything."""
124
+ with io.open(os.path.join(paths.OUT, "callgraph.json"), encoding="utf-8") as f:
125
+ graph = dict((r["document"], set(r["registers"])
126
+ ) for r in json.load(f)["documents"])
127
+ hit = miss = extra = 0
128
+ lines = []
129
+ for document in sorted(recorded):
130
+ actual = set(recorded[document])
131
+ static = graph.get(document, set())
132
+ missed = actual - static
133
+ over = static - actual
134
+ hit += len(actual & static)
135
+ miss += len(missed)
136
+ extra += len(over)
137
+ if missed or over:
138
+ lines.append((document, sorted(missed), sorted(over)))
139
+ out.write(u"%d documents wrote registers in 1C. Register pairs: %d found by the call "
140
+ u"graph, %d missed, %d reached by code but never written\n\n"
141
+ % (len(recorded), hit, miss, extra))
142
+ for document, missed, over in lines:
143
+ if missed:
144
+ out.write(u"%-44s MISSED %s\n" % (document[:44], u", ".join(missed)))
145
+ out.write(u"\n")
146
+ for document, missed, over in lines:
147
+ if over:
148
+ out.write(u"%-44s never written: %s\n" % (document[:44], u", ".join(over)[:150]))
149
+
150
+
151
+ def main():
152
+ out = codecs.getwriter("utf-8")(sys.stdout.buffer)
153
+ if os.path.exists(TARGET) and "--measure" not in sys.argv:
154
+ with io.open(TARGET, encoding="utf-8") as f:
155
+ saved = json.load(f)
156
+ recorded = saved["documents"]
157
+ else:
158
+ recorded, unmatched = measure()
159
+ with codecs.open(TARGET, "w", "utf-8") as f:
160
+ json.dump({"documents": recorded, "unmatched": unmatched}, f,
161
+ ensure_ascii=False, indent="\t", sort_keys=True)
162
+ out.write(u"%d document types wrote registers; %d register/type pairs unmatched\n"
163
+ % (len(recorded), len(unmatched)))
164
+ out.write(u"written %s\n" % TARGET)
165
+ if "--compare" in sys.argv:
166
+ compare(recorded, out)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()