@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.
- package/CHANGELOG.md +45 -0
- package/README.md +2 -1
- package/dist/server.js +71 -3
- package/package.json +3 -3
- package/resources/docs/dsl-reference.md +1 -1
- package/resources/schemas/data-views.schema.json +50 -0
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +375 -0
- package/skills/dforge-1c-import/references/environment.md +98 -0
- package/skills/dforge-1c-import/references/loading.md +164 -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 +164 -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/changes.py +326 -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 +770 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +202 -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 +325 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -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/onecd.py +269 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +68 -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 +91 -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 +493 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
- package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -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/store.py +126 -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/.env.example +24 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
- package/skills/dforge-mcp-author/SKILL.md +5 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
- package/skills/dforge-mcp-author/references/data-views.md +38 -2
- package/skills/dforge-mcp-author/references/formulas.md +32 -1
- package/skills/dforge-mcp-author/references/print-templates.md +30 -5
- package/skills/dforge-mcp-author/references/queries.md +17 -2
- package/skills/dforge-mcp-author/references/registers.md +1 -0
- package/skills/dforge-mcp-author/references/reports.md +1 -1
- package/skills/dforge-mcp-author/references/validation-checklist.md +1 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Validate generated modules against the dForge platform JSON schemas.
|
|
5
|
+
|
|
6
|
+
The schemas live in the platform repo, not here. Point DFORGE_SCHEMAS at them, or rely
|
|
7
|
+
on the default guess of a sibling checkout.
|
|
8
|
+
|
|
9
|
+
python tools/1c.py validate # every module under modules/
|
|
10
|
+
python tools/1c.py validate fleet # one module
|
|
11
|
+
python tools/1c.py validate -w # and list the platform's warnings, not just count them
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import print_function
|
|
14
|
+
|
|
15
|
+
import codecs
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import jsonschema
|
|
22
|
+
|
|
23
|
+
import paths
|
|
24
|
+
|
|
25
|
+
MODULES = paths.MODULES
|
|
26
|
+
SCHEMAS = os.environ.get(
|
|
27
|
+
"DFORGE_SCHEMAS",
|
|
28
|
+
os.path.join(os.path.dirname(paths.ROOT), "dForge-core", "docs", "schemas"))
|
|
29
|
+
|
|
30
|
+
# module file -> schema file; entities are handled separately, one schema for many files
|
|
31
|
+
FILE_SCHEMAS = [
|
|
32
|
+
("manifest.json", "manifest.schema.json"),
|
|
33
|
+
("settings.json", "settings.schema.json"),
|
|
34
|
+
(os.path.join("ui", "data_views.json"), "data_views.schema.json"),
|
|
35
|
+
(os.path.join("ui", "menus.json"), "menus.schema.json"),
|
|
36
|
+
(os.path.join("ui", "folders.json"), "folders.schema.json"),
|
|
37
|
+
(os.path.join("ui", "card_layouts.json"), "card_layouts.schema.json"),
|
|
38
|
+
(os.path.join("security", "roles.json"), "roles.schema.json"),
|
|
39
|
+
("registers.json", "registers.schema.json"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load(path):
|
|
44
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
45
|
+
return json.load(f)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check(doc, schema, label, problems):
|
|
49
|
+
validator = jsonschema.Draft7Validator(schema)
|
|
50
|
+
errors = sorted(validator.iter_errors(doc), key=lambda e: list(e.path))
|
|
51
|
+
for error in errors:
|
|
52
|
+
where = "/".join(str(p) for p in error.path) or "(root)"
|
|
53
|
+
problems.append("%s :: %s :: %s" % (label, where, error.message.split("\n")[0][:160]))
|
|
54
|
+
return not errors
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_translations(base, entities, problems):
|
|
58
|
+
"""There is no platform schema for translations, so check them against the entities
|
|
59
|
+
they translate: no unknown codes, and no half-translated entity.
|
|
60
|
+
|
|
61
|
+
An entity that is absent from the file is not an error. Ukrainian is authored per
|
|
62
|
+
contour as that contour is reviewed, and the platform falls back to the base label
|
|
63
|
+
for anything it does not find; what would be an error is a translated entity with
|
|
64
|
+
some of its fields left behind, because that shows as a card in two languages.
|
|
65
|
+
`coverage()` reports the gap instead."""
|
|
66
|
+
directory = os.path.join(base, "translations")
|
|
67
|
+
if not os.path.isdir(directory):
|
|
68
|
+
return 0
|
|
69
|
+
checked = 0
|
|
70
|
+
for name in sorted(os.listdir(directory)):
|
|
71
|
+
doc = load(os.path.join(directory, name))
|
|
72
|
+
label = "translations/" + name
|
|
73
|
+
for entity_code, entry in doc.get("entities", {}).items():
|
|
74
|
+
entity = entities.get(entity_code)
|
|
75
|
+
if entity is None:
|
|
76
|
+
problems.append("%s :: unknown entity %s" % (label, entity_code))
|
|
77
|
+
continue
|
|
78
|
+
columns = entity["fields"]
|
|
79
|
+
translated = entry.get("fields", {})
|
|
80
|
+
for field in translated:
|
|
81
|
+
if field not in columns:
|
|
82
|
+
problems.append("%s :: %s :: unknown field %s"
|
|
83
|
+
% (label, entity_code, field))
|
|
84
|
+
missing = [c for c in columns if c not in translated]
|
|
85
|
+
if missing:
|
|
86
|
+
problems.append("%s :: %s :: untranslated: %s"
|
|
87
|
+
% (label, entity_code, ", ".join(sorted(missing))))
|
|
88
|
+
checked += 1
|
|
89
|
+
return checked
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def coverage(base, entities):
|
|
93
|
+
"""How many of a module's entities carry a Ukrainian translation."""
|
|
94
|
+
path = os.path.join(base, "translations", "uk-UA.json")
|
|
95
|
+
if not os.path.isfile(path):
|
|
96
|
+
return 0, len(entities)
|
|
97
|
+
doc = load(path)
|
|
98
|
+
return len(doc.get("entities", {})), len(entities)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Postgres reserved words (SQL:2016 reserved set as Postgres classifies it). A column
|
|
102
|
+
# or table code from this list installs fine through the schemas and then fails the
|
|
103
|
+
# import with `db_error: syntax error at or near "..."`, because not every statement
|
|
104
|
+
# dForge generates quotes its identifiers. Cheaper to never emit one.
|
|
105
|
+
RESERVED = set("""
|
|
106
|
+
all analyse analyze and any array as asc asymmetric authorization binary both case cast
|
|
107
|
+
check collate collation column concurrently constraint create cross current_catalog
|
|
108
|
+
current_date current_role current_schema current_time current_timestamp current_user
|
|
109
|
+
default deferrable desc distinct do else end except false fetch for foreign freeze from
|
|
110
|
+
full grant group having ilike in initially inner intersect into is isnull join lateral
|
|
111
|
+
leading left like limit localtime localtimestamp natural not notnull null offset on only
|
|
112
|
+
or order outer overlaps placing primary references returning right select session_user
|
|
113
|
+
similar some symmetric table tablesample then to trailing true union unique user using
|
|
114
|
+
variadic verbose when where window with
|
|
115
|
+
""".split())
|
|
116
|
+
|
|
117
|
+
# `manifest.schema.json` still carries the pre-release audit vocabulary. The installer
|
|
118
|
+
# copies the manifest value straight into `dForge.entity.audit_history`, whose check
|
|
119
|
+
# constraint CK_Entity_Audit_History takes 'basic' / 'fields' / 'full' and nothing else,
|
|
120
|
+
# so a manifest validated against the published enum fails the install. The DB wins.
|
|
121
|
+
AUDIT_HISTORY = ["basic", "fields", "full"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_reserved(entities, problems):
|
|
125
|
+
"""No entity or column may be named after a Postgres reserved word."""
|
|
126
|
+
for entity_code, doc in sorted(entities.items()):
|
|
127
|
+
table = doc.get("dbObject") or entity_code
|
|
128
|
+
if table.lower() in RESERVED:
|
|
129
|
+
problems.append("entities/%s.json :: table %s is a reserved word"
|
|
130
|
+
% (entity_code, table))
|
|
131
|
+
for field in doc.get("fields", {}):
|
|
132
|
+
if field.lower() in RESERVED:
|
|
133
|
+
problems.append("entities/%s.json :: column %s is a reserved word"
|
|
134
|
+
% (entity_code, field))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_registers():
|
|
138
|
+
"""Every declared register across all modules, keyed "module.register_cd".
|
|
139
|
+
|
|
140
|
+
Collected module-wide because a binding may name another module's register, which is
|
|
141
|
+
the whole point of the register being an object: one module owns what it means, and
|
|
142
|
+
anything that depends on it binds rather than restates.
|
|
143
|
+
"""
|
|
144
|
+
declared = {}
|
|
145
|
+
for code in sorted(os.listdir(MODULES)):
|
|
146
|
+
path = os.path.join(MODULES, code, "registers.json")
|
|
147
|
+
if not os.path.isfile(path):
|
|
148
|
+
continue
|
|
149
|
+
for register_cd, definition in load(path).items():
|
|
150
|
+
declared["%s.%s" % (code, register_cd)] = (code, definition)
|
|
151
|
+
return declared
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def path_problem(path, owner, owner_cd, entities, code=None):
|
|
155
|
+
"""Why a one-hop reference path `<reference>.<column>` cannot be read, or None.
|
|
156
|
+
|
|
157
|
+
The platform reads `dateField`, `map` and `headerMap` values written this way through
|
|
158
|
+
one LEFT JOIN at posting time (`RegisterRegistrar.ValidateRefPathAsync` refuses the same
|
|
159
|
+
things at install). The linked entity is checked only when this module declares it.
|
|
160
|
+
"""
|
|
161
|
+
parts = path.split(".")
|
|
162
|
+
if len(parts) != 2 or not all(parts):
|
|
163
|
+
return "is not a single-hop path '<reference>.<column>'"
|
|
164
|
+
root, target = parts
|
|
165
|
+
field = owner.get("fields", {}).get(root)
|
|
166
|
+
if field is None:
|
|
167
|
+
return "starts at %s, which is not a column of %s" % (root, owner_cd)
|
|
168
|
+
link = field.get("link") or {}
|
|
169
|
+
if field.get("columnType") != "R" or not link.get("entity") or not link.get("thisKey"):
|
|
170
|
+
return "starts at %s.%s, which is not a reference column with a link" % (owner_cd, root)
|
|
171
|
+
module, _, linked_cd = link["entity"].rpartition(".")
|
|
172
|
+
linked = entities.get(linked_cd) if module in ("", code) else None
|
|
173
|
+
if linked is not None:
|
|
174
|
+
column = linked.get("fields", {}).get(target)
|
|
175
|
+
if column is None:
|
|
176
|
+
return "reads %s, which is not a column of %s" % (target, link["entity"])
|
|
177
|
+
if column.get("columnType") in ("R", "S", "F"):
|
|
178
|
+
return "reads %s.%s, which is virtual" % (link["entity"], target)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# What an EXPRESSION reads: `[column]` off its own row, or `[reference].[column]` one hop
|
|
183
|
+
# away. A binding value starting with `=` is an F-column formula the platform translates to
|
|
184
|
+
# SQL at posting time (`RegisterEngine.Paths`), and install refuses one reading a column the
|
|
185
|
+
# row does not have, a path it cannot follow, or a document column left unfrozen - each of
|
|
186
|
+
# which is knowable from the JSON, so it is asked here first.
|
|
187
|
+
EXPRESSION_READ = re.compile(r"\[(\w+)\](?:\.\[(\w+)\])?")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def expression_reads(value):
|
|
191
|
+
"""The columns and one-hop paths a `=...` expression reads, as plain names and paths."""
|
|
192
|
+
return [root + "." + target if target else root
|
|
193
|
+
for root, target in EXPRESSION_READ.findall(value[1:])]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def source_problem(value, owner, owner_cd, entities, code=None):
|
|
197
|
+
"""Why a binding value - column, one-hop path or expression - cannot be read, or None."""
|
|
198
|
+
reads = expression_reads(value) if value.startswith("=") else [value]
|
|
199
|
+
for read in reads:
|
|
200
|
+
if "." in read:
|
|
201
|
+
why = path_problem(read, owner, owner_cd, entities, code)
|
|
202
|
+
elif read not in owner.get("fields", {}):
|
|
203
|
+
why = "reads %s, which is not a column of %s" % (read, owner_cd)
|
|
204
|
+
else:
|
|
205
|
+
why = None
|
|
206
|
+
if why:
|
|
207
|
+
return why
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def locks_of(path, owner):
|
|
212
|
+
"""The document columns that freeze what a value reads: a column itself, a path's
|
|
213
|
+
reference key, every one of those an expression reads."""
|
|
214
|
+
if path.startswith("="):
|
|
215
|
+
return sorted(set(lock for read in expression_reads(path) for lock in locks_of(read, owner)))
|
|
216
|
+
if "." not in path:
|
|
217
|
+
return [path]
|
|
218
|
+
link = owner.get("fields", {}).get(path.split(".")[0], {}).get("link") or {}
|
|
219
|
+
return [c.strip() for c in (link.get("thisKey") or "").split(",") if c.strip()]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_registers(code, entities, declared, problems):
|
|
223
|
+
"""What the platform checks at install, checked here instead.
|
|
224
|
+
|
|
225
|
+
`RegisterRegistrar.ValidateBindingsAsync` refuses an undeclared register, an unmapped
|
|
226
|
+
required resource and a map naming a column that is not real. All of that is knowable
|
|
227
|
+
from the generated JSON, and finding it here costs a second rather than a failed
|
|
228
|
+
install against a tenant holding 30 M rows.
|
|
229
|
+
"""
|
|
230
|
+
own = {key: value for key, value in declared.items() if key.startswith(code + ".")}
|
|
231
|
+
|
|
232
|
+
# The declaration itself: the state entity must exist and carry every dimension and
|
|
233
|
+
# every resource field, or the register keys rows on columns that are not there.
|
|
234
|
+
for register_cd, (owner, definition) in sorted(own.items()):
|
|
235
|
+
entity_cd = definition.get("entity")
|
|
236
|
+
if definition.get("kind") in ("turnover", "accounting"):
|
|
237
|
+
# Neither keeps state. A turnover register IS its journal; an accounting
|
|
238
|
+
# register's postings are, and a balance is read from them. So an `entity`
|
|
239
|
+
# here is a claim about a state table that does not exist.
|
|
240
|
+
if entity_cd:
|
|
241
|
+
problems.append("registers.json :: %s is %s and must name no entity"
|
|
242
|
+
% (register_cd, definition["kind"]))
|
|
243
|
+
continue
|
|
244
|
+
if not entity_cd:
|
|
245
|
+
problems.append("registers.json :: %s names no entity" % register_cd)
|
|
246
|
+
continue
|
|
247
|
+
entity = entities.get(entity_cd)
|
|
248
|
+
if entity is None:
|
|
249
|
+
problems.append("registers.json :: %s names entity %s, which this module does "
|
|
250
|
+
"not declare" % (register_cd, entity_cd))
|
|
251
|
+
continue
|
|
252
|
+
columns = entity.get("fields", {})
|
|
253
|
+
for dimension in definition.get("dimensions", []):
|
|
254
|
+
if dimension not in columns:
|
|
255
|
+
problems.append("registers.json :: %s dimension %s is not a column of %s"
|
|
256
|
+
% (register_cd, dimension, entity_cd))
|
|
257
|
+
for resource, spec in definition.get("resources", {}).items():
|
|
258
|
+
field = spec.get("field", resource)
|
|
259
|
+
if field not in columns:
|
|
260
|
+
problems.append("registers.json :: %s resource %s -> %s is not a column of %s"
|
|
261
|
+
% (register_cd, resource, field, entity_cd))
|
|
262
|
+
|
|
263
|
+
# The bindings: every A column that names a register rather than restating one.
|
|
264
|
+
for entity_cd, entity in sorted(entities.items()):
|
|
265
|
+
for field_cd, field in entity.get("fields", {}).items():
|
|
266
|
+
if field.get("columnType") != "A":
|
|
267
|
+
continue
|
|
268
|
+
params = field.get("params") or {}
|
|
269
|
+
reference = params.get("register")
|
|
270
|
+
where = "entities/%s.json :: %s" % (entity_cd, field_cd)
|
|
271
|
+
|
|
272
|
+
if not reference:
|
|
273
|
+
problems.append("%s is an A column that declares its register inline; "
|
|
274
|
+
"bind a declaration instead" % where)
|
|
275
|
+
continue
|
|
276
|
+
if reference not in declared:
|
|
277
|
+
problems.append("%s binds %s, which no module declares"
|
|
278
|
+
% (where, reference))
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
definition = declared[reference][1]
|
|
282
|
+
|
|
283
|
+
# A line-mode posting reads its values off the line entity; a flat one off the
|
|
284
|
+
# document. Checking the wrong entity would pass a map that cannot resolve.
|
|
285
|
+
source_cd = params.get("lineEntity", entity_cd)
|
|
286
|
+
source = entities.get(source_cd)
|
|
287
|
+
if source is None:
|
|
288
|
+
problems.append("%s posts from %s, which this module does not declare"
|
|
289
|
+
% (where, source_cd))
|
|
290
|
+
continue
|
|
291
|
+
# `groupLinesBy` reads `map` over a group row: the grouped line columns and
|
|
292
|
+
# every column of the document.
|
|
293
|
+
group = params.get("groupLinesBy") or []
|
|
294
|
+
if group:
|
|
295
|
+
line_fields = source.get("fields", {})
|
|
296
|
+
for column in group:
|
|
297
|
+
if column not in line_fields:
|
|
298
|
+
problems.append("%s groups by %s, which is not a column of %s"
|
|
299
|
+
% (where, column, source_cd))
|
|
300
|
+
fields = dict(entity.get("fields", {}))
|
|
301
|
+
fields.update((c, line_fields[c]) for c in group if c in line_fields)
|
|
302
|
+
# ...and a numeric line column mapped to a resource, summed over the group.
|
|
303
|
+
resources_declared = definition.get("resources", {})
|
|
304
|
+
fields.update((c, line_fields[c]) for n, c in (params.get("map") or {}).items()
|
|
305
|
+
if n in resources_declared and c in line_fields)
|
|
306
|
+
source, source_cd = {"fields": fields}, "the group row of " + source_cd
|
|
307
|
+
source_columns = source.get("fields", {})
|
|
308
|
+
|
|
309
|
+
mapping = params.get("map") or {}
|
|
310
|
+
dimensions = set(definition.get("dimensions", []))
|
|
311
|
+
resources = definition.get("resources", {})
|
|
312
|
+
|
|
313
|
+
# A line posting may take part of the movement off the DOCUMENT. The values
|
|
314
|
+
# of `headerMap` are document columns, the values of `map` are line columns,
|
|
315
|
+
# and the platform refuses the two to name the same field - so they are
|
|
316
|
+
# checked against different tables and then treated as one mapping for the
|
|
317
|
+
# questions that follow, which only ask whether a field is bound at all.
|
|
318
|
+
header_map = params.get("headerMap") or {}
|
|
319
|
+
if header_map:
|
|
320
|
+
if not params.get("lineEntity"):
|
|
321
|
+
problems.append("%s carries a headerMap while posting from the "
|
|
322
|
+
"document, where every value is the header's already"
|
|
323
|
+
% where)
|
|
324
|
+
locked = set(params.get("lockedFields") or [])
|
|
325
|
+
for name, column in sorted(header_map.items()):
|
|
326
|
+
if name in mapping:
|
|
327
|
+
problems.append("%s binds %s from both the line and the header"
|
|
328
|
+
% (where, name))
|
|
329
|
+
if name not in dimensions and name not in resources:
|
|
330
|
+
problems.append("%s takes %s from the header, which %s declares "
|
|
331
|
+
"as neither a dimension nor a resource"
|
|
332
|
+
% (where, name, reference))
|
|
333
|
+
elif name in resources and definition.get("kind") != "info":
|
|
334
|
+
# Added once per line otherwise: four lines, four times the value.
|
|
335
|
+
problems.append("%s takes resource %s from the header, and %s is "
|
|
336
|
+
"a %s register" % (where, name, reference,
|
|
337
|
+
definition.get("kind")))
|
|
338
|
+
if "." in column or column.startswith("="):
|
|
339
|
+
why = source_problem(column, entity, entity_cd, entities, code)
|
|
340
|
+
if why:
|
|
341
|
+
problems.append("%s takes %s from %s, which %s" % (where, name, column, why))
|
|
342
|
+
elif column not in entity.get("fields", {}):
|
|
343
|
+
problems.append("%s takes %s from header column %s, which is not "
|
|
344
|
+
"a column of %s" % (where, name, column, entity_cd))
|
|
345
|
+
for lock in locks_of(column, entity):
|
|
346
|
+
if lock not in locked:
|
|
347
|
+
# The posting is keyed by it. Change it under a posted document
|
|
348
|
+
# and the reversal looks for a tuple that was never posted to.
|
|
349
|
+
problems.append("%s reads %s off the header without freezing %s "
|
|
350
|
+
"in lockedFields" % (where, column, lock))
|
|
351
|
+
mapping = dict(header_map, **mapping)
|
|
352
|
+
|
|
353
|
+
for name, column in mapping.items():
|
|
354
|
+
if name in header_map and name not in (params.get("map") or {}):
|
|
355
|
+
continue # checked against the document above, not the line
|
|
356
|
+
|
|
357
|
+
if name not in dimensions and name not in resources:
|
|
358
|
+
problems.append("%s maps %s, which %s declares as neither a dimension "
|
|
359
|
+
"nor a resource" % (where, name, reference))
|
|
360
|
+
# A quoted value is a constant to stamp, not a column to read - which is
|
|
361
|
+
# how a document that asserts one fixed status says so, exactly as the 1C
|
|
362
|
+
# handler assigns an enum value rather than reading a field. The platform
|
|
363
|
+
# accepts it on the header path and the line path alike.
|
|
364
|
+
if column.startswith("'") and column.endswith("'") and len(column) > 2:
|
|
365
|
+
# The platform stamps whatever is between the quotes without looking at
|
|
366
|
+
# it. If the register's column is a dropdown, a value outside its
|
|
367
|
+
# options is a row the UI cannot render and no filter will find, and
|
|
368
|
+
# nothing downstream would say so - so it is said here.
|
|
369
|
+
target = entities.get(definition.get("entity"), {}).get("fields", {})
|
|
370
|
+
options = ((target.get(resources.get(name, {}).get("field", name), {})
|
|
371
|
+
.get("params") or {}).get("options"))
|
|
372
|
+
if options and column[1:-1] not in [o.get("value") for o in options]:
|
|
373
|
+
problems.append(
|
|
374
|
+
"%s stamps %s into %s, which offers %s"
|
|
375
|
+
% (where, column, name,
|
|
376
|
+
", ".join(o.get("value") for o in options)))
|
|
377
|
+
continue
|
|
378
|
+
if "." in column or column.startswith("="):
|
|
379
|
+
why = source_problem(column, source, source_cd, entities, code)
|
|
380
|
+
if why:
|
|
381
|
+
problems.append("%s maps %s -> %s, which %s" % (where, name, column, why))
|
|
382
|
+
continue
|
|
383
|
+
if column not in source_columns:
|
|
384
|
+
problems.append("%s maps %s -> %s, which is not a column of %s"
|
|
385
|
+
% (where, name, column, source_cd))
|
|
386
|
+
|
|
387
|
+
# An unmapped required resource posts a silent zero, so the platform refuses
|
|
388
|
+
# it. A resource only some writers carry is marked optional in the declaration.
|
|
389
|
+
for resource, spec in resources.items():
|
|
390
|
+
if spec.get("required", True) and resource not in mapping:
|
|
391
|
+
problems.append("%s leaves required resource %s of %s unmapped"
|
|
392
|
+
% (where, resource, reference))
|
|
393
|
+
|
|
394
|
+
# A dimension may bind to a column of the same name without being mapped, so
|
|
395
|
+
# an absent one is only wrong when the source has no such column either.
|
|
396
|
+
for dimension in dimensions:
|
|
397
|
+
if dimension not in mapping and dimension not in source_columns:
|
|
398
|
+
problems.append("%s neither maps dimension %s nor carries a column of "
|
|
399
|
+
"that name" % (where, dimension))
|
|
400
|
+
|
|
401
|
+
# A register that dates its journal needs to be told which column supplies it.
|
|
402
|
+
if definition.get("journalDate") and not params.get("dateField"):
|
|
403
|
+
problems.append("%s binds dated register %s but names no dateField"
|
|
404
|
+
% (where, reference))
|
|
405
|
+
date_field = params.get("dateField")
|
|
406
|
+
if date_field and ("." in date_field or date_field.startswith("=")):
|
|
407
|
+
why = source_problem(date_field, entity, entity_cd, entities, code)
|
|
408
|
+
if why:
|
|
409
|
+
problems.append("%s names dateField %s, which %s" % (where, date_field, why))
|
|
410
|
+
elif date_field and date_field not in entity.get("fields", {}):
|
|
411
|
+
problems.append("%s names dateField %s, which is not a column of %s"
|
|
412
|
+
% (where, date_field, entity_cd))
|
|
413
|
+
if date_field and definition.get("journalDate"):
|
|
414
|
+
locked = set(params.get("lockedFields") or [])
|
|
415
|
+
if "*" not in locked:
|
|
416
|
+
for lock in locks_of(date_field, entity):
|
|
417
|
+
if lock not in locked:
|
|
418
|
+
# A posted document re-dated, or pointed at another record,
|
|
419
|
+
# leaves what it posted under the old date.
|
|
420
|
+
problems.append("%s reads its date through %s without freezing %s "
|
|
421
|
+
"in lockedFields" % (where, date_field, lock))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# The schemas say what a file may look like, not whether the module holds together: every
|
|
425
|
+
# dependency needs a deps/ contract, every entity a menu opens must be granted to its
|
|
426
|
+
# folder, every grid needs a column to draw. The platform checks those in its own CLI, and
|
|
427
|
+
# the editor extension runs that same command - so a module this script passed was still
|
|
428
|
+
# refused there. Asking the CLI keeps the two from disagreeing again.
|
|
429
|
+
def check_static(base, problems, warnings):
|
|
430
|
+
"""The platform's static checks, as `dforge-cli module validate` runs them (tools/lib/cli.py).
|
|
431
|
+
|
|
432
|
+
Its warnings are kept apart: they fail nothing, but the editor shows every one of them,
|
|
433
|
+
and a script that dropped them is how 6000 of them went unnoticed.
|
|
434
|
+
"""
|
|
435
|
+
import cli
|
|
436
|
+
try:
|
|
437
|
+
_, text = cli.run(["module", "validate", base, "--json"])
|
|
438
|
+
except SystemExit as missing:
|
|
439
|
+
problems.append("static checks skipped: %s" % missing)
|
|
440
|
+
return
|
|
441
|
+
try:
|
|
442
|
+
result = json.loads(text[text.index("{"):])
|
|
443
|
+
except ValueError:
|
|
444
|
+
problems.append("static checks: the CLI printed no result - %s" % text[:200])
|
|
445
|
+
return
|
|
446
|
+
if result.get("error"):
|
|
447
|
+
problems.append("static checks :: %s" % result["error"])
|
|
448
|
+
for found in result.get("warnings", []):
|
|
449
|
+
warnings.append("%s :: %s" % (found.get("where") or "(module)", found.get("message")))
|
|
450
|
+
for found in result.get("checks", []):
|
|
451
|
+
if not found["ok"]:
|
|
452
|
+
for line in (found.get("message") or "").splitlines():
|
|
453
|
+
if line.strip().startswith("- "):
|
|
454
|
+
problems.append("%s :: %s" % (found["name"], line.strip()[2:]))
|
|
455
|
+
if not any(p.startswith(found["name"] + " ::") for p in problems):
|
|
456
|
+
problems.append("%s :: %s" % (found["name"], found.get("message")))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def validate_module(code, show_warnings=False):
|
|
460
|
+
base = os.path.join(MODULES, code)
|
|
461
|
+
problems, warnings, checked = [], [], 0
|
|
462
|
+
|
|
463
|
+
for relative, schema_name in FILE_SCHEMAS:
|
|
464
|
+
path = os.path.join(base, relative)
|
|
465
|
+
schema_path = os.path.join(SCHEMAS, schema_name)
|
|
466
|
+
if not os.path.isfile(path):
|
|
467
|
+
continue
|
|
468
|
+
if not os.path.isfile(schema_path):
|
|
469
|
+
problems.append("%s :: no schema %s" % (relative, schema_name))
|
|
470
|
+
continue
|
|
471
|
+
schema = load(schema_path)
|
|
472
|
+
if schema_name == "manifest.schema.json":
|
|
473
|
+
schema["properties"]["auditHistory"]["enum"] = AUDIT_HISTORY
|
|
474
|
+
check(load(path), schema, relative, problems)
|
|
475
|
+
checked += 1
|
|
476
|
+
|
|
477
|
+
entity_schema = load(os.path.join(SCHEMAS, "entity.schema.json"))
|
|
478
|
+
entity_dir = os.path.join(base, "entities")
|
|
479
|
+
entities = {}
|
|
480
|
+
# A module of actions or reports alone has no entities.
|
|
481
|
+
for name in sorted(os.listdir(entity_dir)) if os.path.isdir(entity_dir) else []:
|
|
482
|
+
doc = load(os.path.join(entity_dir, name))
|
|
483
|
+
entities[name[:-len(".json")]] = doc
|
|
484
|
+
check(doc, entity_schema, "entities/" + name, problems)
|
|
485
|
+
checked += 1
|
|
486
|
+
|
|
487
|
+
check_reserved(entities, problems)
|
|
488
|
+
check_registers(code, entities, load_registers(), problems)
|
|
489
|
+
checked += check_translations(base, entities, problems)
|
|
490
|
+
check_static(base, problems, warnings)
|
|
491
|
+
translated, total = coverage(base, entities)
|
|
492
|
+
|
|
493
|
+
print("%-10s %3d files checked, %d problems, %d warnings, uk %d/%d entities"
|
|
494
|
+
% (code, checked, len(problems), len(warnings), translated, total))
|
|
495
|
+
for problem in problems:
|
|
496
|
+
print(" %s" % problem)
|
|
497
|
+
for warning in (warnings if show_warnings else []):
|
|
498
|
+
print(" warn %s" % warning)
|
|
499
|
+
return not problems
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
if __name__ == "__main__":
|
|
503
|
+
if not os.path.isdir(SCHEMAS):
|
|
504
|
+
print("schemas not found at %s - set DFORGE_SCHEMAS" % SCHEMAS)
|
|
505
|
+
sys.exit(2)
|
|
506
|
+
show = "-w" in sys.argv[1:]
|
|
507
|
+
targets = [a for a in sys.argv[1:] if a != "-w"] or sorted(os.listdir(MODULES))
|
|
508
|
+
sys.exit(0 if all([validate_module(c, show) for c in targets]) else 1)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copy to <port root>/.env and fill in. `.env` never goes into a commit.
|
|
2
|
+
#
|
|
3
|
+
# None of these has a default, deliberately: a default naming one infobase would make the
|
|
4
|
+
# toolkit quietly read or write that infobase whenever a variable was forgotten.
|
|
5
|
+
|
|
6
|
+
# DF1C_ROOT and DF1C_PROJECT are exported in the shell, not set here: this file is found
|
|
7
|
+
# through DF1C_ROOT (<root>/.env), so it cannot say where the root is.
|
|
8
|
+
|
|
9
|
+
# The infobase, read-only: a server infobase on MS SQL (with DF1C_USER empty, Windows
|
|
10
|
+
# authentication is used) ...
|
|
11
|
+
DF1C_SERVER=
|
|
12
|
+
DF1C_DB=
|
|
13
|
+
DF1C_USER=
|
|
14
|
+
DF1C_PASS=
|
|
15
|
+
# ... or a file infobase: the 1Cv8.1CD, or the folder holding it. Set this or the four above.
|
|
16
|
+
# Point it at a copy, or close the infobase first.
|
|
17
|
+
DF1C_FILE=
|
|
18
|
+
|
|
19
|
+
# The dForge tenant and its Postgres, for loading and checks
|
|
20
|
+
DF_TENANT=
|
|
21
|
+
DF_PG_HOST=localhost
|
|
22
|
+
DF_PG_PORT=
|
|
23
|
+
DF_PG_USER=
|
|
24
|
+
DF_PG_PASS=
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Which 1C object belongs to which dForge module - this infobase's half of the port.
|
|
3
|
+
|
|
4
|
+
Start it from the configuration's own subsystems rather than by hand:
|
|
5
|
+
|
|
6
|
+
python 1c.py subsystems --contours --acyclic # -> out/contours_subsystems.py
|
|
7
|
+
|
|
8
|
+
and copy that file over this one. Rename the module codes, move objects between contours
|
|
9
|
+
where the subsystems guessed wrong, and keep the names below. `binding.py` in the library
|
|
10
|
+
lists everything a project may say; only CONTOURS and CODES are required.
|
|
11
|
+
"""
|
|
12
|
+
import collections
|
|
13
|
+
|
|
14
|
+
# module code -> {"objects": [1C object names], ...}, in install order
|
|
15
|
+
CONTOURS = collections.OrderedDict([
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
# 1C object name -> the entity code to give it, where the derived one is wrong
|
|
19
|
+
CODES = {}
|
|
20
|
+
|
|
21
|
+
# (module, entity, column) -> the module that should own the column, for a reference that
|
|
22
|
+
# would otherwise invert a dependency
|
|
23
|
+
EXTENSIONS = {}
|
|
24
|
+
|
|
25
|
+
# 1C objects an authored spec covers (generation skips them)
|
|
26
|
+
DONE = set()
|
|
27
|
+
|
|
28
|
+
# 1C object name -> "module.entity" for the objects an authored spec owns: a reference to
|
|
29
|
+
# one resolves to that entity instead of generating a second copy
|
|
30
|
+
HAND_ENTITIES = {}
|
|
31
|
+
|
|
32
|
+
# The integrator's own prefixes (e.g. u"мсб" for мсбЗаказ): what makes an object the
|
|
33
|
+
# client's rather than the vendor's. Measure them: list the lowercase runs before a capital
|
|
34
|
+
# across the object names (`1c.py custom`). Empty means nothing counts as the client's.
|
|
35
|
+
CLIENT_PREFIXES = ()
|
|
36
|
+
|
|
37
|
+
# Prefixes of a third-party toolkit embedded in the configuration: neither the vendor's
|
|
38
|
+
# nor the client's. Dropped from derived names like the client's.
|
|
39
|
+
TOOLKIT_PREFIXES = ()
|
|
40
|
+
|
|
41
|
+
# Where the minimal entities for catalogs the port only points at are generated
|
|
42
|
+
STUB_MODULE = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def names(contour):
|
|
46
|
+
"""The 1C object names one contour owns."""
|
|
47
|
+
return list(contour["objects"])
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Nothing is authored in this project, so there is nothing to map by hand.
|
|
3
|
+
|
|
4
|
+
Every module here is generated from the configuration's own subsystems, and `autospec`
|
|
5
|
+
records each column's 1C source as it emits the column. These four names exist because
|
|
6
|
+
`binding.py` requires them; they stay empty for as long as no module here is written by
|
|
7
|
+
hand. If one ever is, this is where its columns say where they came from - see
|
|
8
|
+
`tools/project/sources.py` for the shape.
|
|
9
|
+
|
|
10
|
+
A query in QUERIES runs on the export (`lib/store.py`), so it is DuckDB SQL, not T-SQL:
|
|
11
|
+
`"_Fld123"` rather than `[_Fld123]`, `LIMIT` rather than `TOP`, `from_hex('01')` rather
|
|
12
|
+
than `0x01`.
|
|
13
|
+
|
|
14
|
+
Put a `/*window:<alias>*/` marker in the WHERE of the scan it narrows, and do not turn
|
|
15
|
+
T-SQL's CROSS APPLY into a LATERAL join: DuckDB applies a filter written above a LATERAL
|
|
16
|
+
after the join, so a query narrowed to one recorder still reads every analytic row - on
|
|
17
|
+
99.8 M it ran out of memory and spilled 17 GB. A nested SELECT over the table, with the
|
|
18
|
+
marker in its own WHERE, ran in under a second. Spill is capped at DF1C_DUCKDB_TEMP (8GB).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
ENUMS = {}
|
|
22
|
+
TABLEREFS = {}
|
|
23
|
+
QUERIES = {}
|
|
24
|
+
SOURCES = {}
|
|
@@ -14,6 +14,11 @@ three stage skills, each loaded only when its stage is active:
|
|
|
14
14
|
| `dforge-module-build` | 1–5 | Entities, behavior, views/menus, polish, security. |
|
|
15
15
|
| `dforge-module-ship` | 6 | Validate → pre-pack review → pack → install-fix loop. |
|
|
16
16
|
|
|
17
|
+
**Porting from 1C** (a 1C:Enterprise infobase, `_Reference`/`_Document`/`_AccumRg`
|
|
18
|
+
tables, регистры, проведение) is its own skill, `dforge-1c-import`: the porting
|
|
19
|
+
decisions, the row copy and the print forms, with a Python toolkit for the mechanical
|
|
20
|
+
half. It produces modules this skill set then validates and installs.
|
|
21
|
+
|
|
17
22
|
**Why split:** each stage needs a different half of the knowledge base. Phase 0
|
|
18
23
|
needs none of the field-type/DSL detail; Phase 6 needs none of the intake
|
|
19
24
|
guardrails. Loading only the active stage is what keeps a full module build
|
|
@@ -359,7 +359,7 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
|
|
|
359
359
|
|
|
360
360
|
| Function | Returns | Description |
|
|
361
361
|
|---|---|---|
|
|
362
|
-
| `printPdf('module.entity', key, templateCd)` | `{ fileId, fileName, mimeType, size, checksum }` | Render a print template to a PDF **as the current user** (the record is read under their rights and the folder's row filter) and archive it. A descriptor, never the bytes: attach it with `sendEmail()`. Always pass a qualified entity code. Not available in a scheduled job or a background action, refused in a computing-only run, max 10 per run; each render holds the action's transaction open. |
|
|
362
|
+
| `printPdf('module.entity', key, templateCd)` | `{ fileId, fileName, mimeType, size, checksum }`, plus `warnings` when a value printed empty because the user may not read it | Render a print template to a PDF **as the current user** (the record is read under their rights and the folder's row filter) and archive it. A descriptor, never the bytes: attach it with `sendEmail()`. Always pass a qualified entity code. Not available in a scheduled job or a background action, refused in a computing-only run, max 10 per run; each render holds the action's transaction open. |
|
|
363
363
|
| `downloadText(content, fileName, encoding?, mimeType?)` | URL string | Hand the user a text file the script built. `encoding` is `utf-8` by default; `windows-1251`, `cp866`, `koi8-u` and other code pages work, and a character the encoding cannot represent is an error. Line endings are written as given (join with `'\r\n'` for CR LF), no BOM. Returns the signed download URL (valid an hour). An action has one download: a later call replaces the earlier. In a background or scheduled run nobody receives it, so pass the URL on in `notify()`. Max 10 MB per file, 10 files per run. |
|
|
364
364
|
| `attachText(content, fileName, encoding?, mimeType?)` | descriptor | Build a text file for `sendEmail()`'s fourth argument, keeping its `mimeType` (e.g. an `.ics` as `text/calendar`). Same encodings as `downloadText()`; max 10 MB per file, 10 files per run shared with the download functions. Works in a scheduled job. |
|
|
365
365
|
| `attachBase64(base64, fileName, mimeType?)` | descriptor | `attachText()` for bytes. |
|