@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,210 @@
1
+ # The measurements
2
+
3
+ Each decision in SKILL.md has a query that settles it. They are written against a loaded
4
+ dForge tenant (Postgres) because that is where both sides are comparable — the 1C rows and
5
+ what the platform writes. Where the question can only be asked of the infobase, the query
6
+ is against the export (DuckDB over `out/data/`, via `store.connect()`) and says so.
7
+
8
+ Adapt names; the shapes are the point.
9
+
10
+ ## Contents
11
+
12
+ 1. Does this register need a collapse?
13
+ 2. Which kind is it?
14
+ 3. Would accumulation ever accumulate?
15
+ 4. Did the collapse actually happen?
16
+ 5. Is a guard in the handler alive?
17
+ 6. Does anything read this?
18
+ 7. Is a column's type a lie?
19
+ 8. Is a text column really full?
20
+ 9. Which costing method does the base use?
21
+
22
+ ---
23
+
24
+ ## 1. Does this register need a collapse?
25
+
26
+ The handler's `Свернуть` is the signal; these two confirm it and size it.
27
+
28
+ ```sql
29
+ -- how often the lines of one document repeat a dimension
30
+ select repeats, count(*) as documents from (
31
+ select <doc_fk>, count(*) - count(distinct <dim>) as repeats
32
+ from <line_table> group by 1) x
33
+ group by 1 order by 1 limit 10;
34
+
35
+ -- lines in, rows 1C actually wrote out, for the same documents
36
+ select sum(lines) as lines, sum(rows_1c) as rows_1c from (
37
+ select d.<pk> id,
38
+ (select count(*) from <line_table> l where l.<doc_fk> = d.<pk>) as lines,
39
+ (select count(*) from <register> r where r.recorder = d.<pk>) as rows_1c
40
+ from <documents> d) y;
41
+ ```
42
+
43
+ `lines > rows_1c` means the collapse is load-bearing.
44
+
45
+ ## 2. Which kind is it?
46
+
47
+ ```sql
48
+ select count(*) as rows,
49
+ count(distinct (<dims>)) as distinct_keys,
50
+ count(distinct recorder) as writing_documents
51
+ from <register>;
52
+ ```
53
+
54
+ `rows = distinct_keys` means one row per key — a table of records, not a journal. Combine
55
+ with question 1: one row per key *and* repeating lines means the writer collapses, which
56
+ is `balance` in dForge regardless of the 1C family.
57
+
58
+ ## 3. Would accumulation ever accumulate?
59
+
60
+ ```sql
61
+ select docs_per_key, count(*) as keys from (
62
+ select <key columns>, count(*) as docs_per_key
63
+ from <documents>
64
+ where coalesce(deletion_mark, false) = false
65
+ group by <key columns>) z
66
+ group by 1 order by 1;
67
+ ```
68
+
69
+ One output row showing `1` retires the objection that `balance` would add two openings
70
+ together. Record the number in the declaration; it is the answer to the question the next
71
+ reader will ask.
72
+
73
+ ## 4. Did the collapse actually happen?
74
+
75
+ Two traps here, and they pull in opposite directions.
76
+
77
+ Resource **totals** match whether the engine collapsed or not, because a sum is a sum. So
78
+ totals alone cannot show a collapse. But row counts alone cannot either: against a
79
+ register that already holds the migrated rows, a posting that did **nothing** adds no rows
80
+ and creates no duplicate keys — and so passes a check built only on counts. A posting that
81
+ updated the *wrong* existing key passes it too.
82
+
83
+ What settles it is the expected delta **per full dimension tuple**, checked in both
84
+ directions. Compute what the lines say each tuple should move, before posting:
85
+
86
+ ```sql
87
+ -- expected, read through the binding's own map and headerMap
88
+ select <dims>, sum(<resource>) as expected
89
+ from <line_table> l join <documents> d on d.<pk> = l.<doc_fk>
90
+ where d.<pk> = '<doc>'
91
+ group by <dims>;
92
+ ```
93
+
94
+ Then post, and compare per tuple rather than in aggregate:
95
+
96
+ ```sql
97
+ select r.<dims>, r.<resource> - b.<resource> as moved
98
+ from <register> r join <baseline snapshot> b using (<dims>);
99
+ ```
100
+
101
+ Three things must hold together, and any one alone is weak:
102
+
103
+ 1. every expected tuple moved by exactly its expected amount;
104
+ 2. no tuple outside the expected set moved at all;
105
+ 3. after unposting, every tuple is back at the baseline.
106
+
107
+ Point 2 is what catches an update to the wrong key; point 3 is what catches a posting that
108
+ silently did nothing, because a no-op also unposts to nothing and the baseline never
109
+ moved. Snapshot the register's rows for the affected tuples before posting so the
110
+ comparison has something to be against.
111
+
112
+ And separately, since it is cheap:
113
+
114
+ ```sql
115
+ select count(*) as keys_with_two_rows from (
116
+ select 1 from <register> group by <dims> having count(*) > 1) x;
117
+ ```
118
+
119
+ ## 5. Is a guard in the handler alive?
120
+
121
+ `Если Стр.Количество <> 0` guards a loop. Has it ever fired?
122
+
123
+ ```sql
124
+ select count(*) as lines,
125
+ count(*) filter (where <col> = 0) as would_be_skipped,
126
+ count(*) filter (where <col> <> 0) as actually_written
127
+ from <line_table>;
128
+ ```
129
+
130
+ Zero skipped over years of data means the guard is dead code. Porting it as a platform
131
+ feature would be paying for nothing.
132
+
133
+ ## 6. Does anything read this?
134
+
135
+ A grep over the extracted configuration modules, not a query. Count the objects naming the
136
+ register, split by whether the object is the client's own (lowercase prefix before a
137
+ capital) or the vendor's, and look at what appears near each mention. If the only mentions
138
+ are the writing document and `УдалитьДвиженияРегистратора`, nothing reads it.
139
+
140
+ Do this before carrying a journal, a recorder link, or any mechanism that exists to
141
+ support a reader.
142
+
143
+ ## 7. Is a column's type a lie?
144
+
145
+ Sweep every text column at once rather than meeting them one at a time. Build the union
146
+ from `information_schema.columns` in the module schemas, then:
147
+
148
+ ```sql
149
+ select '<schema>.<table>.<col>' as col, count(*) as n
150
+ from "<schema>"."<table>"
151
+ where "<col>" ~ '^[0-9]{2}\.[0-9]{2}\.[0-9]{4}( |$)'
152
+ ```
153
+
154
+ A hit is a date the 1C author declared as a string. Expect very few — in the base measured,
155
+ one column out of 611 — which is what makes the sweep worth running once and never
156
+ worrying again.
157
+
158
+ ## 8. Is a text column really full?
159
+
160
+ ```sql
161
+ select count(*) as rows,
162
+ count(*) filter (where <col> is null) as nulls,
163
+ count(*) filter (where <col> = '') as empty_strings,
164
+ count(*) filter (where coalesce(<col>, '') <> '') as actually_filled
165
+ from <table>;
166
+ ```
167
+
168
+ `nulls = 0` while `actually_filled = 0` is the signature of a column read from a field the
169
+ infobase never writes. It is invisible to `count(column)`.
170
+
171
+ ## 9. Which costing method does the base use?
172
+
173
+ The accounting policy register will name an enum position and the configuration text may
174
+ not resolve it. The movements answer it several ways, and agreement across them is the
175
+ point — any one alone can mislead.
176
+
177
+ ```sql
178
+ -- A. batches touched by one write-off. Average costing writes off proportionally across
179
+ -- every batch in stock, so a heavy concentration at 1 rules it out.
180
+ select batches, count(*) as expenses from (
181
+ select _RecorderRRef, <item>, <warehouse>, count(distinct <batch>) as batches
182
+ from <batch_register>
183
+ where _RecordKind = 1 and _Period >= ? and _Period < ?
184
+ group by 1,2,3) x
185
+ group by 1 order by 1;
186
+
187
+ -- B. distinct unit costs per item and warehouse within one month.
188
+ -- A period-weighted average gives exactly one.
189
+ select prices, count(*) from (
190
+ select <item>, <warehouse>,
191
+ count(distinct round(<amount> / nullif(<qty>, 0), 4)) as prices
192
+ from <batch_register>
193
+ where _RecordKind = 1 and _Period >= ? and _Period < ? and <qty> <> 0
194
+ group by 1,2) y
195
+ group by 1 order by 1;
196
+
197
+ -- C. was there a choice at all? (validity check: zero-stock cases mean the
198
+ -- balance arithmetic is wrong and nothing below can be believed)
199
+ -- then, among those with a choice, was the oldest batch in stock taken?
200
+ -- FIFO takes the oldest; LIFO the newest; neither dominating means no rule
201
+ -- is followed and the batch is effectively identified rather than queued.
202
+ ```
203
+
204
+ Read C carefully: "the oldest batch ever received" is a different question from "the oldest
205
+ batch that still had stock at that moment", and only the second is FIFO. The first gives a
206
+ muddle and will mislead you.
207
+
208
+ Whatever the answer, it changes the question put to the client rather than replacing it:
209
+ not *"which method do you use"* — the base answers that — but *"is that what you meant, and
210
+ should it stay that way"*. A rule nobody enforces may be accepted practice or an accident.
@@ -0,0 +1,148 @@
1
+ # Porting 1C print forms
2
+
3
+ The platform's side of this (manifest, context, snippets, Scriban versus LiquidJS) is in
4
+ `dforge://reference/print-templates`; queries are in `dforge://reference/queries`. This
5
+ file covers what a 1C form brings with it.
6
+
7
+ ## What a 1C form is
8
+
9
+ 1C prints in two steps:
10
+
11
+ 1. The document's print procedure (`Печать…`, often in a shared module) computes almost
12
+ every value. It folds lines by product and series, takes places and packs through the
13
+ series' packaging, looks prices up at the date, totals, and assembles the
14
+ organization's requisites into one text.
15
+ 2. A spreadsheet template (макет, `MOXCEL`) is filled area by area with those values.
16
+
17
+ So the template alone is not the form. **Read the procedure first.** It holds the
18
+ requirement. The template is only the layout.
19
+
20
+ The dForge form mirrors the two steps:
21
+
22
+ | 1C | dForge |
23
+ |---|---|
24
+ | the print procedure | the template's datasets: a query, with F columns for per-line arithmetic |
25
+ | the template (макет) | an HTML template with its CSS |
26
+ | the shared printing library (requisites, contact info, amounts in words) | shared datasets and snippets, e.g. the platform's `common-words` for amounts and quantities in words |
27
+
28
+ ## Reading the template
29
+
30
+ A template is the object's metadata group `{3daea016-69b7-4ed4-9453-127911372fe6, n,
31
+ guid…}`. Each template's `<guid>.txt` names it and its body is `<guid>.0.txt`. A spreadsheet
32
+ body starts `MOXCEL` and continues as a brace list:
33
+
34
+ - **Rows** with their cells. A cell is one of:
35
+ - `{0, format}` — empty;
36
+ - `{16, format, {1,1,{lang,text}}}` — text, or a parameter when `lang` is `""`;
37
+ - `{20, …, {"U"}, …}` — a template string whose `[Name]` parts are parameters.
38
+ - **Column sets**, **merges** and **named areas**. An area is rows (kind 1) or columns
39
+ (kind 2), with inclusive bounds.
40
+ - **Formats.** Each is a bit mask followed by its values, low bit first: font index (1),
41
+ left/top/right/bottom border (2/4/8/16), height (64), width (128). The remaining bits
42
+ (alignment, placement) can be carried by number.
43
+ - **Fonts.**
44
+
45
+ Dump each template to HTML and JSON: areas, cells, parameters, merges, widths, fonts,
46
+ borders. **Author the form from the dump; do not convert it.** 1C's layout is a grid of up
47
+ to ~50 narrow columns. A faithful HTML copy of it is unmaintainable. Keep the form's
48
+ logical columns and labels.
49
+
50
+ Which areas the procedure outputs, and in what order, is the form's real structure. Typical
51
+ areas are header, line, subtotal per group, total and signatures.
52
+
53
+ ## Inventory before porting
54
+
55
+ - **List the forms per document from the print command's handler**, not from the
56
+ templates. An object can carry dead templates. Templates the code names only for dates
57
+ before some year print only old documents: record them and skip them.
58
+ - **Count how often each variant fires** on recent documents. A variant is a template
59
+ chosen by a flag on the buyer or the contract, or a "per code" text. A variant used by
60
+ 278 documents since last year is worth a template. One used by none may still be worth a
61
+ column.
62
+ - **External print processings** (`ВнешниеОбработки`) live in the infobase as stored
63
+ binaries, not in the configuration. Inventory them separately.
64
+
65
+ ## Reproduce what 1C prints, including its slips
66
+
67
+ The client compares the new printout to the old one. Where 1C's code has a defect, the
68
+ printout has the defect, and the client knows the printout. Reproduce it, and say so in
69
+ a comment at the place. Common ones:
70
+
71
+ - **A misspelt variable** makes a lookup find nothing. For example, a phone kind read from
72
+ a misnamed variable means no phone ever prints. Print nothing.
73
+ - **A field list with spaces** (`"НомерСчета, Банк, АдресБанка"`) passed where 1C expects
74
+ no spaces fills only the first field.
75
+ - **A test on the wrong attribute**, such as an IBAN check on the account's name instead
76
+ of its number. Do the same test.
77
+ - **Mixed Cyrillic and Latin letters** in a literal (`с` against `c`). Copy them byte for
78
+ byte.
79
+ - **Language codes** in `НСтр("ru = '…'; en = '…'", Lang)` are matched case-insensitively,
80
+ so `"Ru"` finds `ru`.
81
+
82
+ If the client later asks to fix one, it is a one-line change with the comment pointing
83
+ at it.
84
+
85
+ ## Formatting numbers and dates as 1C does
86
+
87
+ | 1C | output | Postgres equivalent |
88
+ |---|---|---|
89
+ | `Строка(n)`, `Формат(n)` without a format | thousands grouped by nbsp, decimal comma, no trailing zeros | `replace(replace(rtrim(rtrim(to_char(n,'FM999G999G999G990D9999999999'),'0'),'.'),',',E' '),'.',',')` (the `G`/`D` result depends on `lc_numeric`; check it) |
90
+ | `Формат(n, "ЧДЦ=2")` | always 2 decimals | `to_char(n, 'FM999G999G990D00')` then the same separators |
91
+ | `Формат(n, "ЧРД=.")` | decimal point | the replacement above with `.` |
92
+ | `Формат(n, "ЧГ=0")` | no grouping | drop `G` |
93
+ | `Формат(d, "ДФ=dd.MM.yyyy")` | `13.01.2016` | `to_char(d, 'DD.MM.YYYY')` |
94
+ | a date parameter output as is | `13.01.2016 9:05:00` (hour without a leading zero) | `to_char(d, 'DD.MM.YYYY FMHH24:MI:SS')` |
95
+
96
+ Before choosing the precision a price prints with, measure how many stored values actually
97
+ have more decimals. Do not trust the declared precision: stored prices can carry a third
98
+ decimal on most rows.
99
+
100
+ Amounts and quantities in words should come from a shared snippet (the platform's
101
+ `common-words`), not per-form code. Verify it on the client's real values against 1C's
102
+ `ЧислоПрописью` output: thousands of values, every case. Expect differences only where 1C
103
+ is ungrammatical, and agree them with the client.
104
+
105
+ ## Datasets: queries, and where a query falls short
106
+
107
+ Prefer a query dataset. It runs under the user's rights and folder filters, which a stored
108
+ procedure bypasses.
109
+
110
+ - **Put per-line arithmetic into F columns on the line entity**: weight = places × place
111
+ weight, packs, the VAT percentage through a `SWITCH`, a group section through `IN`.
112
+ - **Let the query group and sum** (`agg: group | sum`).
113
+ - **Accumulate totals in the template.** The server engine has no `where`, `sum` or
114
+ `group_by` filters, but `plus`, `times`, `round` and `ceil` work.
115
+ - **A dataset is always an array.** Take a header row with
116
+ `{% assign h = header | first %}`.
117
+
118
+ A query cannot (yet) express these, so the dataset stays a procedure until it can:
119
+
120
+ - a value in force at a date (`СрезПоследних`: prices, positions, codes, shelf lives);
121
+ - a filter through a reverse set or a polymorphic reference;
122
+ - string aggregation;
123
+ - a join on columns that are not a reference;
124
+ - formulas over aggregates.
125
+
126
+ 1C rounds after folding (`round(price × SUM(qty))`). A query sums a per-line formula. The two
127
+ differ only where lines fold. Measure how many documents that touches, then agree it.
128
+
129
+ ## Verifying a ported form
130
+
131
+ 1. **Compare the dataset with the procedure's output.** Pick tens of random recent
132
+ documents and compare every field, line by line. Zero differences is the bar. An F
133
+ column rewritten from a procedure gets the same check.
134
+ 2. **Render previews on real documents** for every variant: each language, each flag,
135
+ with and without the optional blocks. Screenshot them against 1C's printout where one
136
+ is available.
137
+ 3. **Measure the data each block depends on.** How many recent documents have the date,
138
+ the indicators, the shelf life? A block empty on every document is either 1C's state
139
+ (record it) or a column read from the wrong source (fix it).
140
+
141
+ ## Generating templates
142
+
143
+ When several forms share blocks (requisites, a lines table, signatures, a certificate
144
+ body), write the templates from one generator. Do not hand-copy HTML. Keep the generator
145
+ in the project, regenerate on every change, and never edit its output.
146
+
147
+ In Python, keep each formatted literal its own expression. Adjacent string literals
148
+ concatenate **before** `%` applies, which produces format errors far from the cause.
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ One way in, so the split between library and project stays a fact rather than a habit.
5
+
6
+ python tools/1c.py <command> [args...]
7
+ python tools/1c.py --list # what there is
8
+ python tools/1c.py --boundary # does the library still stand on its own?
9
+
10
+ `tools/lib` is 1C and dForge: how a configuration is stored, how its metadata parses,
11
+ what the physical tables mean, and how rows are copied and checked. It is meant to be
12
+ lifted out and pointed at a different infobase.
13
+
14
+ `tools/project` is this infobase: which object belongs to which module, which 1C column
15
+ feeds each ported one, which regulated statements it holds, this company's own proper
16
+ nouns, and the modules written by hand. `DF1C_PROJECT` points the library at a different
17
+ one; `binding.py` lists the whole of what a project has to provide.
18
+
19
+ Both directories go on the path, so the modules import each other by plain name and a
20
+ move between them costs no edits. That convenience is also the hazard, so `--boundary`
21
+ checks two things rather than trusting either: that no library module IMPORTS a project
22
+ one, and that no library module NAMES this project's world - its server, its infobase,
23
+ its tenant, its company's abbreviations. A literal is the quiet way to bind a library to
24
+ one configuration, and it is how `DF1C_DB` came to default to `utp_biscuit` for months.
25
+
26
+ Because both directories are on one path, a project module may not take a name the
27
+ library already uses: `regulated.py` rather than `forms.py`, `vocabulary.py` rather than
28
+ `terms.py`. `--boundary` catches the shadowing as an import crossing.
29
+ """
30
+ from __future__ import print_function
31
+
32
+ import io
33
+ import os
34
+ import sys
35
+
36
+ HERE = os.path.dirname(os.path.abspath(__file__))
37
+ LIB = os.path.join(HERE, "lib")
38
+ PROJECT = os.path.abspath(os.environ.get("DF1C_PROJECT") or os.path.join(HERE, "project"))
39
+
40
+ for entry in (PROJECT, LIB):
41
+ if entry not in sys.path:
42
+ sys.path.insert(0, entry)
43
+
44
+
45
+ def commands():
46
+ """Every runnable module, library or project, mapped to (path, first docstring line)."""
47
+ found = {}
48
+ for directory in (LIB, PROJECT):
49
+ for name in sorted(os.listdir(directory)):
50
+ if not name.endswith(".py") or name.startswith("_") or name[:-3] in found:
51
+ continue
52
+ path = os.path.join(directory, name)
53
+ summary = _runnable(path)
54
+ if summary is not None:
55
+ found[name[:-3]] = (path, summary)
56
+ return found
57
+
58
+
59
+ def _runnable(path):
60
+ """The module's first docstring line if it can be run as a script, else None."""
61
+ with io.open(path, encoding="utf-8") as f:
62
+ text = f.read()
63
+ # Anything runnable: most define main(), a few only a __main__ block.
64
+ if "def main(" not in text and "__name__" not in text:
65
+ return None
66
+ if '"""' not in text:
67
+ return ""
68
+ body = text.split('"""', 2)[1].strip().splitlines()
69
+ return (body[0] if body else "").strip()
70
+
71
+
72
+ def boundary():
73
+ """Two ways the library can stop standing on its own, both read off the source.
74
+
75
+ IMPORTS. The path puts both directories in reach, so nothing stops a library module
76
+ importing `contours` directly and nothing would complain until someone tried to lift
77
+ the library out.
78
+
79
+ NAMES. An import is the loud way to bind to one infobase; a literal is the quiet one.
80
+ `DF1C_DB` defaulting to `utp_biscuit` and `DF_TENANT` to `hbf1` sat in the library for
81
+ a long while, harmless with one infobase and a trap with two - point the tools at a
82
+ second configuration, forget one variable, and they read the first one with
83
+ right-looking output. So the names of THIS project's world are refused in the library,
84
+ including in its comments, since a stale comment is how the next reader learns the
85
+ wrong thing.
86
+
87
+ The list is read from the project rather than written here, so a project that renames
88
+ itself does not have to remember this file.
89
+ """
90
+ import ast
91
+ import re
92
+
93
+ project_modules = set(n[:-3] for n in os.listdir(PROJECT) if n.endswith(".py"))
94
+
95
+ # What this project calls itself: the infobase, the tenant and its own proper nouns.
96
+ reserved = set()
97
+ for variable in ("DF1C_DB", "DF1C_SERVER", "DF_TENANT"):
98
+ value = os.environ.get(variable)
99
+ if value:
100
+ reserved.add(value.lower())
101
+ env_example = os.path.join(os.path.dirname(os.path.abspath(PROJECT)), os.pardir,
102
+ ".env.example")
103
+ if os.path.exists(env_example):
104
+ with io.open(env_example, encoding="utf-8") as f:
105
+ for line in f:
106
+ match = re.match(r"\s*(DF1C_DB|DF1C_SERVER|DF_TENANT)\s*=\s*(\S+)", line)
107
+ if match:
108
+ reserved.add(match.group(2).lower())
109
+ terms = os.path.join(PROJECT, "terms.py")
110
+ if os.path.exists(terms):
111
+ with io.open(terms, encoding="utf-8") as f:
112
+ for value in re.findall(r'u"([^"]+)"\s*:', f.read()):
113
+ if len(value) > 3: # two- and three-letter forms collide with real words
114
+ reserved.add(value.lower())
115
+
116
+ problems = []
117
+ for name in sorted(os.listdir(LIB)):
118
+ if not name.endswith(".py"):
119
+ continue
120
+ with io.open(os.path.join(LIB, name), encoding="utf-8") as f:
121
+ text = f.read()
122
+ tree = ast.parse(text)
123
+ for node in ast.walk(tree):
124
+ named = []
125
+ if isinstance(node, ast.Import):
126
+ named = [a.name.split(".")[0] for a in node.names]
127
+ elif isinstance(node, ast.ImportFrom) and node.module:
128
+ named = [node.module.split(".")[0]]
129
+ for target in named:
130
+ if target in project_modules:
131
+ problems.append("lib/%s imports %s - go through binding.py instead"
132
+ % (name, target))
133
+ # paths.py explains why these names are refused, so it is allowed to name them.
134
+ if name == "paths.py":
135
+ continue
136
+ lowered = text.lower()
137
+ for word in sorted(reserved):
138
+ if word in lowered:
139
+ problems.append("lib/%s names %r - that is the project's, not the library's"
140
+ % (name, word))
141
+ return problems
142
+
143
+
144
+ def main():
145
+ args = sys.argv[1:]
146
+ if not args or args[0] in ("-h", "--help", "--list"):
147
+ import paths
148
+ paths.show()
149
+ print()
150
+ for name, (_, summary) in sorted(commands().items()):
151
+ print(" %-16s %s" % (name, summary[:60]))
152
+ return 0
153
+
154
+ if args[0] == "--boundary":
155
+ problems = boundary()
156
+ for line in problems:
157
+ print(line)
158
+ print("%d crossing(s) of the library/project boundary" % len(problems))
159
+ return 1 if problems else 0
160
+
161
+ name, rest = args[0], args[1:]
162
+ found = commands()
163
+ if name not in found:
164
+ print("no such command: %s (try --list)" % name)
165
+ return 2
166
+ # run_path rather than calling main(): a few tools only have a __main__
167
+ # block, and this runs every one of them exactly as a script would.
168
+ import runpy
169
+ sys.argv = [name] + rest
170
+ try:
171
+ runpy.run_path(found[name][0], run_name="__main__")
172
+ except SystemExit as stop:
173
+ return stop.code or 0
174
+ return 0
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main() or 0)