@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,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. |