@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,90 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Where the working files live.
3
+
4
+ Every tool used to derive this from its own `__file__`, two directories up, which quietly
5
+ assumed the library sits inside the project it serves. It does not have to: the library
6
+ is meant to be lifted out and pointed at a different infobase, and the project it is
7
+ pointed at is then somewhere else entirely.
8
+
9
+ So the root is asked for, not assumed. `DF1C_ROOT` names it; without that, the directory
10
+ holding `tools/` is used, which is what a project laid out like this one gets for free.
11
+ """
12
+ from __future__ import print_function
13
+
14
+ import os
15
+
16
+ HERE = os.path.dirname(os.path.abspath(__file__))
17
+
18
+
19
+ def _default_root():
20
+ # tools/lib/paths.py -> tools -> the project root. Only a library sitting in a
21
+ # project's `tools/` has one; a copy anywhere else (an installed skill) would write
22
+ # the dump into its own directory, so it asks instead.
23
+ if os.path.basename(os.path.dirname(HERE)) != "tools":
24
+ raise SystemExit("set DF1C_ROOT to the porting project's directory (and DF1C_PROJECT"
25
+ " to its project package): this library is not inside one")
26
+ return os.path.dirname(os.path.dirname(HERE))
27
+
28
+
29
+ ROOT = os.path.abspath(os.environ.get("DF1C_ROOT") or _default_root())
30
+
31
+ # What the extraction writes and everything downstream reads. Kept separate from ROOT
32
+ # because a second infobase dumped beside the first only differs here.
33
+ OUT = os.path.abspath(os.environ.get("DF1C_OUT") or os.path.join(ROOT, "out"))
34
+
35
+ # Generated dForge modules.
36
+ MODULES = os.path.abspath(os.environ.get("DF1C_MODULES") or os.path.join(ROOT, "modules"))
37
+
38
+ # The project's own package - the contour map, the column map and the authored specs.
39
+ # `DF1C_PROJECT` points the library at a different one without moving anything.
40
+ PROJECT = os.path.abspath(os.environ.get("DF1C_PROJECT")
41
+ or os.path.join(os.path.dirname(HERE), "project"))
42
+
43
+
44
+ def required(name, what):
45
+ u"""An environment value the library cannot invent, with a default that would be a lie.
46
+
47
+ `DF1C_DB` used to default to `utp_biscuit` and `DF_TENANT` to `hbf1` - this project's own
48
+ names, sitting in the library. Harmless while there is one infobase and a trap as soon as
49
+ there are two: pointing the tools at a second configuration and forgetting one variable
50
+ would have read or written the FIRST one, quietly and with the right-looking output. So
51
+ the library asks and stops rather than guessing.
52
+ """
53
+ value = os.environ.get(name)
54
+ if value:
55
+ return value
56
+ raise SystemExit(
57
+ "%s is not set - %s.\n"
58
+ " This is the project's to say, not the library's. Copy .env.example to .env and\n"
59
+ " fill it in, or export the variable for this shell." % (name, what))
60
+
61
+
62
+ def derived(name):
63
+ """Where a file BUILT FROM THE PROJECT goes, as opposed to one read from the infobase.
64
+
65
+ `out/` holds two different kinds of thing. The extraction - report.json, dbnames.json,
66
+ the configuration dump, the module sources - is about the infobase and is the same
67
+ whoever is porting it. `columnmap.json` and `registers.json` are about the PROJECT:
68
+ which of its entities copies which 1C column, which of its modules declares which
69
+ register. Pointing the library at a second project and rebuilding the first kind is
70
+ free; rebuilding the second kind on top of the first project's copy destroys it.
71
+
72
+ Which is what happened: one `columnmap` run against the standard-configuration project
73
+ overwrote the client port's map with 21 modules the client port has never heard of.
74
+ `migrate` reads that file, so the next load would have copied by a map for other
75
+ entities entirely - and the file is committed, so the damage outlives the session.
76
+
77
+ The default project keeps writing where it always did, so nothing that exists moves.
78
+ Any other project gets a subdirectory named after it.
79
+ """
80
+ if os.path.basename(PROJECT) == "project":
81
+ return os.path.join(OUT, name)
82
+ folder = os.path.join(OUT, os.path.basename(PROJECT))
83
+ if not os.path.isdir(folder):
84
+ os.makedirs(folder)
85
+ return os.path.join(folder, name)
86
+
87
+
88
+ def show():
89
+ for name in ("ROOT", "OUT", "MODULES", "PROJECT"):
90
+ print("%-8s %s" % (name, globals()[name]))
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Show sample rows of a physical table, with 1C's standard columns decoded.
5
+
6
+ Attribute lists from the configuration omit the implicit fields every catalog and
7
+ document carries (_Code, _Description, _Date_Time, _Posted, _Marked), so the only
8
+ way to see what actually lives in them is to look at the data.
9
+
10
+ python tools/peek.py _Reference214 # 5 rows, all columns
11
+ python tools/peek.py _Reference214 10 # 10 rows
12
+ python tools/peek.py --find рзВодители # resolve a metadata name to its table
13
+ """
14
+ from __future__ import print_function
15
+
16
+ import codecs
17
+ import json
18
+ import os
19
+ import sys
20
+
21
+ import paths
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
24
+
25
+ import dump1c
26
+
27
+ OUT = paths.OUT
28
+
29
+
30
+ def resolve(name):
31
+ with codecs.open(os.path.join(OUT, "report.json"), "r", "utf-8") as f:
32
+ for row in json.load(f):
33
+ if row.get("name") == name:
34
+ return row
35
+ return None
36
+
37
+
38
+ def render(value):
39
+ if value is None:
40
+ return "-"
41
+ if isinstance(value, (bytes, bytearray)):
42
+ return value.hex()[:16]
43
+ text = str(value).strip()
44
+ return text[:38] if text else "-"
45
+
46
+
47
+ def main():
48
+ if sys.argv[1] == "--find":
49
+ row = resolve(sys.argv[2])
50
+ print(json.dumps(row, ensure_ascii=False, indent="\t") if row else "not found")
51
+ return
52
+
53
+ table = sys.argv[1]
54
+ limit = int(sys.argv[2]) if len(sys.argv) > 2 else 5
55
+ cur = dump1c.connect(paths.required("DF1C_SERVER", "which MS SQL server holds it"),
56
+ paths.required("DF1C_DB",
57
+ "which MS SQL infobase to read")).cursor()
58
+ cur.execute("SELECT TOP (%d) * FROM [%s]" % (limit, table))
59
+ columns = [d[0] for d in cur.description]
60
+ rows = cur.fetchall()
61
+ # drop columns that are empty across every sampled row - they only add noise
62
+ keep = [i for i, _ in enumerate(columns)
63
+ if any(render(r[i]) not in ("-", "0", "0.0000") for r in rows)]
64
+ for i in keep:
65
+ print("%-28s | %s" % (columns[i], " | ".join(render(r[i]) for r in rows)))
66
+ print("\n(%d columns, %d hidden as empty)" % (len(columns), len(columns) - len(keep)))
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Mark the migrated documents that 1C posted as posted in the source system.
5
+
6
+ The loader brings **both** sides across from the infobase: the documents and the register
7
+ rows they wrote. The platform's posting flag is not among them - it is engine state, and a
8
+ generated trigger refuses any write to it but the engine's (`DF006`) - so every migrated
9
+ document arrives saying "not posted" while its movements already sit in the register. Left
10
+ alone, a user opens a 2018 document, sees it unposted, presses Post, and the engine writes a
11
+ second set of rows on top of the migrated ones.
12
+
13
+ The platform's answer is a distinct state rather than a claim that the engine posted it:
14
+ `register.markImported` sets the hidden marker pair beside a state column
15
+ (`<state>_imported`, `<state>_imported_source`). A marked document is settled - the engine
16
+ refuses to post or unpost it, its locked fields freeze, and the card shows it read-only under
17
+ an "Imported" badge - while its state column stays false, so "posted" keeps meaning "the
18
+ engine posted this".
19
+
20
+ python tools/1c.py posted # measure: what 1C posted, what is marked
21
+ DF_TOKEN=<jwt> python tools/1c.py posted --mark # make the markers match 1C
22
+ DF_TOKEN=<jwt> python tools/1c.py posted --unmark # take every marker off again
23
+
24
+ What gets marked is exactly what 1C asserted: `posted` is the infobase's own `Проведен`, so a
25
+ document 1C left unposted stays postable here, and its movements were never written either.
26
+ `--mark` converges on that: it also takes the marker off a document 1C did not post (an
27
+ earlier run marked without the condition), since a marked draft could never be posted here.
28
+ It is idempotent - the RPC skips a document already marked or already posted - so it is safe
29
+ to run after every load, and it has to be: a reload of a document table brings the rows back
30
+ unmarked.
31
+
32
+ It is deliberately **not** part of `migrate run`. Marking is a statement about history made
33
+ once the history is in place, and it needs a token the loader does not have.
34
+
35
+ Nothing here stamps `source` on the migrated register rows. That column exists so unposting
36
+ can find the rows it wrote, and a marked document is never unposted.
37
+ """
38
+ from __future__ import print_function
39
+
40
+ import argparse
41
+ import sys
42
+
43
+ import migrate
44
+ import paths
45
+ import posting
46
+
47
+
48
+ def source():
49
+ """The label a marker carries: which infobase the document was posted in."""
50
+ return "1C:%s" % paths.required("DF1C_DB", "which MS SQL infobase the documents came from")
51
+
52
+
53
+ def registry_columns(pg):
54
+ """Every posting column in the tenant - `A` and ledger `L` alike - with its module and
55
+ the table it sits on.
56
+
57
+ Read from the tenant's own metadata rather than listed here, so a register declared
58
+ tomorrow is covered without editing this file - the failure mode otherwise is a new
59
+ binding quietly missing from the sweep, which is exactly the bug this guards against.
60
+ It was one: the sweep read `A` only, and the first ledger binding would have left every
61
+ document 1C posted offered Post again, doubling its postings in the ledger.
62
+
63
+ A binding may keep its state in a field of its own (`stateField`), and an extension's
64
+ columns sit in a side table (`storage_table`) joined on the document's key: the column
65
+ returned is the state the marker sits beside, and the storage is that side table or None.
66
+ """
67
+ cur = pg.cursor()
68
+ cur.execute("""
69
+ SELECT m.module_cd, e.entity_cd, e.schema_name, e.db_object,
70
+ coalesce(c.params->>'stateField', c.column_cd), s.storage_table,
71
+ (SELECT k.column_cd FROM "dForge".entity_column k
72
+ WHERE k.entity_id = e.entity_id AND k.is_pk LIMIT 1)
73
+ FROM "dForge".entity_column c
74
+ JOIN "dForge".entity e ON e.entity_id = c.entity_id
75
+ JOIN "dForge".module m ON m.module_id = e.module_id
76
+ LEFT JOIN "dForge".entity_column s
77
+ ON s.entity_id = e.entity_id AND s.column_cd = coalesce(c.params->>'stateField', c.column_cd)
78
+ WHERE c.column_type IN ('A', 'L')
79
+ ORDER BY m.module_cd, e.entity_cd, c.column_cd""")
80
+ return [row for row in cur.fetchall() if row[2] and row[3]]
81
+
82
+
83
+ def storage_of(schema, table, storage):
84
+ """(schema, table) the state and its marker are kept in."""
85
+ if not storage:
86
+ return schema, table
87
+ side_schema, side_table = storage.split(".", 1)
88
+ return side_schema.strip('"'), side_table.strip('"')
89
+
90
+
91
+ def has_column(pg, schema, table, column):
92
+ cur = pg.cursor()
93
+ cur.execute("""SELECT 1 FROM information_schema.columns
94
+ WHERE table_schema = %s AND table_name = %s AND column_name = %s""",
95
+ (schema, table, column))
96
+ return cur.fetchone() is not None
97
+
98
+
99
+ def counts(pg, schema, table, column, storage=None, key=None):
100
+ """(1C posted, engine posted, marked, still to mark, marked though 1C did not post).
101
+ An extension's state is read from its side table, where a document it never wrote has no row."""
102
+ marker = column + "_imported"
103
+ side = ' LEFT JOIN %s x ON x."%s" = t."%s"' % (storage, key, key) if storage else ""
104
+ cur = pg.cursor()
105
+ cur.execute("""SELECT count(*) FILTER (WHERE posted),
106
+ count(*) FILTER (WHERE "{c}"),
107
+ count(*) FILTER (WHERE "{m}"),
108
+ count(*) FILTER (WHERE posted AND NOT coalesce("{c}", false) AND NOT coalesce("{m}", false)),
109
+ count(*) FILTER (WHERE "{m}" AND NOT coalesce(posted, false))
110
+ FROM "{s}"."{t}" t{x}""".format(c=column, m=marker, s=schema, t=table, x=side))
111
+ return cur.fetchone()
112
+
113
+
114
+ def folder_of(pg, module):
115
+ # As a string: the RPC declares folderId as one.
116
+ cur = pg.cursor()
117
+ cur.execute('select f.folder_id from "dForge".folder f '
118
+ 'join "dForge".module m on m.module_id = f.module_id '
119
+ 'where m.module_cd = %s order by f.folder_id limit 1', (module,))
120
+ row = cur.fetchone()
121
+ return str(row[0]) if row else None
122
+
123
+
124
+ def survey(pg, module=None, unmark=False, quiet=False):
125
+ """The table of what 1C posted against what is marked, and the work it implies.
126
+
127
+ Returns (work, total, stale): one (module, label, column) per registry column that can
128
+ be marked, the number of documents a converge would touch, and the modules installed
129
+ before the marker pair existed.
130
+ """
131
+ columns = [c for c in registry_columns(pg) if not module or c[0] == module]
132
+ out = (lambda *a: None) if quiet else print
133
+ out("%-52s %-22s %9s %9s %9s %9s %9s"
134
+ % ("document", "state", "1C", "posted", "marked", "to mark", "stray"))
135
+ out("-" * 126)
136
+ work, total, stale = [], 0, []
137
+ for module_cd, entity_cd, schema, table, column, storage, key in columns:
138
+ label = "%s.%s" % (module_cd, entity_cd)
139
+ if not has_column(pg, schema, table, "posted"):
140
+ out("%-52s %-22s skipped: no `posted` column to read" % (label[:52], column[:22]))
141
+ continue
142
+ if not has_column(pg, *(storage_of(schema, table, storage) + (column + "_imported",))):
143
+ # Installed before the platform synthesised the marker pair. Marking refuses and
144
+ # says to reinstall; saying so here costs nothing.
145
+ stale.append(label)
146
+ out("%-52s %-22s no marker column: reinstall the module" % (label[:52], column[:22]))
147
+ continue
148
+ in_1c, posted_here, marked, pending, stray = counts(pg, schema, table, column, storage, key)
149
+ total += marked if unmark else pending + stray
150
+ work.append((module_cd, label, column))
151
+ out("%-52s %-22s %9s %9s %9s %9s %9s"
152
+ % (label[:52], column[:22], format(in_1c, ","), format(posted_here, ","),
153
+ format(marked, ","), format(pending, ","), format(stray, ",")))
154
+ out("-" * 126)
155
+ out("%-98s %9s" % ("to unmark" if unmark else "to mark or unmark", format(total, ",")))
156
+ return work, total, stale
157
+
158
+
159
+ def converge(pg, work, jwt, unmark=False):
160
+ """Make the markers match 1C (or take every one off): {verb: count}, and whether all went.
161
+
162
+ --mark marks what 1C posted and takes the marker off what it did not. The delta loader
163
+ calls this too, after it has upserted documents: a document it brought in is unmarked,
164
+ and one 1C has since unposted is still marked.
165
+ """
166
+ steps = [("register.unmarkImported", "true", "unmarked")] if unmark else [
167
+ ("register.markImported", "t.posted", "marked"),
168
+ ("register.unmarkImported", "NOT coalesce(t.posted, false)", "unmarked")]
169
+ failed = False
170
+ done = {}
171
+ for module, label, column in work:
172
+ for method, where, verb in steps:
173
+ params = {"entity": label, "stateColumn": column, "where": where}
174
+ if method == "register.markImported":
175
+ params["source"] = source()
176
+ result = posting.call(method, params, folder_of(pg, module), jwt)
177
+ count = (result.get("result") or {}).get("count") if isinstance(result, dict) else None
178
+ if count is None:
179
+ failed = True
180
+ print(" %-52s %s" % (label, result), file=sys.stderr)
181
+ continue
182
+ done[verb] = done.get(verb, 0) + count
183
+ if count:
184
+ print(" %-52s %-22s %s %s" % (label, column, verb, format(count, ",")))
185
+ return done, not failed
186
+
187
+
188
+ def main():
189
+ parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
190
+ action = parser.add_mutually_exclusive_group()
191
+ action.add_argument("--mark", action="store_true",
192
+ help="mark every document 1C posted (needs a token)")
193
+ action.add_argument("--unmark", action="store_true",
194
+ help="remove every marker (needs a token)")
195
+ parser.add_argument("module", nargs="?", help="one module only")
196
+ args = parser.parse_args()
197
+
198
+ migrate.load_env()
199
+ pg = migrate.pg_connect()
200
+ pg.autocommit = True
201
+ if not any(not args.module or c[0] == args.module for c in registry_columns(pg)):
202
+ print("no registry columns in this tenant")
203
+ return
204
+ work, total, _ = survey(pg, args.module, args.unmark)
205
+
206
+ if not (args.mark or args.unmark):
207
+ if total:
208
+ print("\nnothing written. --mark (with a token) marks them.")
209
+ return
210
+
211
+ done, ok = converge(pg, work, posting.token(), args.unmark)
212
+ print("\n" + ", ".join("%s %s" % (verb, format(n, ",")) for verb, n in done.items())
213
+ + " document(s).")
214
+ if not ok:
215
+ sys.exit(1)
216
+
217
+ if __name__ == "__main__":
218
+ main()