@dialecte/create 0.0.1

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 (52) hide show
  1. package/README.md +88 -0
  2. package/dist/cli/index.js +281 -0
  3. package/package.json +43 -0
  4. package/python/generate/__init__.py +1 -0
  5. package/python/generate/__main__.py +103 -0
  6. package/python/generate/collector.py +128 -0
  7. package/python/generate/deriver.py +117 -0
  8. package/python/generate/emitters/__init__.py +1 -0
  9. package/python/generate/emitters/constants.py +69 -0
  10. package/python/generate/emitters/definition.py +49 -0
  11. package/python/generate/emitters/ts_helpers.py +283 -0
  12. package/python/generate/emitters/types.py +67 -0
  13. package/python/generate/extractors/__init__.py +1 -0
  14. package/python/generate/extractors/attributes.py +83 -0
  15. package/python/generate/extractors/children.py +175 -0
  16. package/python/generate/extractors/constraints.py +154 -0
  17. package/python/generate/extractors/docs.py +32 -0
  18. package/python/generate/extractors/facets.py +168 -0
  19. package/python/generate/extractors/namespace.py +59 -0
  20. package/python/generate/globals.py +99 -0
  21. package/python/generate/helpers.py +143 -0
  22. package/python/generate/ir.py +81 -0
  23. package/python/generate/orphans.py +69 -0
  24. package/python/generate/xpath_parser.py +167 -0
  25. package/python/generate/xsi_type.py +150 -0
  26. package/python/pyproject.toml +15 -0
  27. package/templates/dialecte/README.md +39 -0
  28. package/templates/dialecte/_gitignore +4 -0
  29. package/templates/dialecte/docs/.vitepress/config.ts +15 -0
  30. package/templates/dialecte/docs/index.md +18 -0
  31. package/templates/dialecte/env.d.ts +1 -0
  32. package/templates/dialecte/package.json +45 -0
  33. package/templates/dialecte/src/__version__/config/dialecte.config.ts +48 -0
  34. package/templates/dialecte/src/__version__/config/hydrated.types.ts +78 -0
  35. package/templates/dialecte/src/__version__/config/index.ts +2 -0
  36. package/templates/dialecte/src/__version__/config/namespaces.ts +6 -0
  37. package/templates/dialecte/src/__version__/definition/.gitkeep +2 -0
  38. package/templates/dialecte/src/__version__/definition/index.ts +4 -0
  39. package/templates/dialecte/src/__version__/dialecte.ts +30 -0
  40. package/templates/dialecte/src/__version__/extensions/index.ts +3 -0
  41. package/templates/dialecte/src/__version__/index.ts +2 -0
  42. package/templates/dialecte/src/__version__/test/hydrated-test.ts +53 -0
  43. package/templates/dialecte/src/__version__/test/index.ts +1 -0
  44. package/templates/dialecte/src/index.ts +1 -0
  45. package/templates/dialecte/tsconfig.build.json +24 -0
  46. package/templates/dialecte/tsconfig.json +8 -0
  47. package/templates/dialecte/tsconfig.node.json +13 -0
  48. package/templates/dialecte/tsconfig.vitest.json +10 -0
  49. package/templates/dialecte/vite.config.ts +36 -0
  50. package/templates/dialecte/vitest.config.ts +35 -0
  51. package/vendor/elementpath-5.1.1-py3-none-any.whl +0 -0
  52. package/vendor/xmlschema-4.3.1-py3-none-any.whl +0 -0
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @dialecte/create
2
+
3
+ Scaffold and generate [Dialecte](https://github.com/dialecte) SDKs from an XSD schema.
4
+
5
+ The XSD-to-TypeScript generator is written in Python but runs inside Node via
6
+ [Pyodide](https://pyodide.org/) (WebAssembly), so **end users do not need Python
7
+ installed**. Pure-Python dependencies are vendored as wheels and installed
8
+ offline at runtime.
9
+
10
+ ## Quick start
11
+
12
+ Scaffold a brand-new dialecte package from a schema:
13
+
14
+ ```sh
15
+ npm create @dialecte -- ./my-schema.xsd --name @acme/widget
16
+ ```
17
+
18
+ > `npm create @dialecte` resolves to the `@dialecte/create` package. The `--`
19
+ > separator is required so npm forwards the schema path and flags to the CLI
20
+ > instead of consuming them itself.
21
+
22
+ Or just regenerate definition files into an existing package:
23
+
24
+ ```sh
25
+ npx @dialecte/create generate --entry ./my-schema.xsd --out-dir ./src/v1/definition
26
+ ```
27
+
28
+ ## Commands
29
+
30
+ ### `create <schema.xsd>`
31
+
32
+ Scaffolds a new dialecte package (built on `@dialecte/core`) and generates its
33
+ element definitions from the schema in one step.
34
+
35
+ | Option | Default | Description |
36
+ | ---------------------- | ----------------------------- | ------------------------------ |
37
+ | `--name <pkg>` | `@dialecte/<schema basename>` | npm package name |
38
+ | `--out <dir>` | `./<dialecte id>` | target directory |
39
+ | `--version <vN>` | `v1` | version folder name |
40
+ | `--namespace <uri>` | `urn:dialecte:<id>` | default XML namespace URI |
41
+ | `--core-version <ver>` | `^0.2.19` | `@dialecte/core` version range |
42
+
43
+ The generated package includes:
44
+
45
+ - Hydrated type aliases bound to your config (`Dialecte.Project`, `Dialecte.Query`, ...)
46
+ - A project factory (`create<Name>Project`)
47
+ - Test hydration utilities wired to `@dialecte/core/test`
48
+ - VitePress documentation scaffolding
49
+
50
+ ### `generate`
51
+
52
+ Generates only the three definition files
53
+ (`definition.generated.ts`, `constants.generated.ts`, `types.generated.ts`).
54
+
55
+ | Option | Description |
56
+ | ---------------------- | --------------------------- |
57
+ | `--entry <schema.xsd>` | entry XSD file (required) |
58
+ | `--out-dir <dir>` | output directory (required) |
59
+
60
+ ## Bring your own XSD
61
+
62
+ This package does **not** bundle IEC or other proprietary schemas. Point it at
63
+ your own `.xsd` file. Optional sidecar files next to the entry XSD are honored:
64
+
65
+ - `parent-mapping.json` - declares parents for orphan (wildcard) elements
66
+ - `attribute-mapping.json` - injects extension-namespace attributes
67
+
68
+ ## Development
69
+
70
+ The generator engine is a Python package under `python/`. Develop and test it
71
+ with native Python (fast loop), then ship it to users via WebAssembly.
72
+
73
+ ```sh
74
+ # Engine (Python) tests
75
+ cd python && python -m pytest
76
+
77
+ # Vendor the runtime wheels (network required; run once / on dep bump)
78
+ npm run vendor
79
+
80
+ # Build the Node CLI
81
+ npm run build
82
+
83
+ # Try it
84
+ node dist/cli/index.js generate --entry ./xsd/SCL/IEC61850-6-100.xsd --out-dir .tmp/out
85
+ ```
86
+
87
+ `xsd/` and `local/` are git-ignored: they hold local-only schemas and the
88
+ maintainer's batch generation script.
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { existsSync as existsSync2 } from "fs";
5
+ import { basename as basename2, resolve as resolve4 } from "path";
6
+ import { fileURLToPath as fileURLToPath2 } from "url";
7
+
8
+ // src/cli/pyodide-runner.ts
9
+ import { readdir, mkdir } from "fs/promises";
10
+ import { dirname as dirname2, basename, resolve as resolve2 } from "path";
11
+
12
+ // src/cli/paths.ts
13
+ import { dirname, resolve } from "path";
14
+ import { fileURLToPath } from "url";
15
+ var here = dirname(fileURLToPath(import.meta.url));
16
+ var PACKAGE_ROOT = resolve(here, "..", "..");
17
+ var PYTHON_ENGINE_DIR = resolve(PACKAGE_ROOT, "python");
18
+ var VENDOR_DIR = resolve(PACKAGE_ROOT, "vendor");
19
+ var TEMPLATES_DIR = resolve(PACKAGE_ROOT, "templates");
20
+
21
+ // src/cli/pyodide-runner.ts
22
+ import { loadPyodide } from "pyodide";
23
+ var pyodidePromise;
24
+ function fs(py) {
25
+ return py.FS;
26
+ }
27
+ async function getPyodide(quiet) {
28
+ if (!pyodidePromise) {
29
+ pyodidePromise = loadPyodide({
30
+ stdout: quiet ? () => {
31
+ } : (msg) => console.log(msg),
32
+ stderr: (msg) => console.error(msg)
33
+ });
34
+ }
35
+ return pyodidePromise;
36
+ }
37
+ async function mountReadOnly(py, mountPoint, hostRoot) {
38
+ const f = fs(py);
39
+ try {
40
+ f.mkdir(mountPoint);
41
+ } catch {
42
+ }
43
+ f.mount(f.filesystems.NODEFS, { root: hostRoot }, mountPoint);
44
+ }
45
+ async function listWheels() {
46
+ const entries = await readdir(VENDOR_DIR);
47
+ return entries.filter((f) => f.endsWith(".whl"));
48
+ }
49
+ async function runGenerator(options) {
50
+ const { entry, outDir, quiet = false } = options;
51
+ const entryAbs = resolve2(entry);
52
+ const outAbs = resolve2(outDir);
53
+ const entryDir = dirname2(entryAbs);
54
+ const entryName = basename(entryAbs);
55
+ await mkdir(outAbs, { recursive: true });
56
+ const py = await getPyodide(quiet);
57
+ await mountReadOnly(py, "/engine", PYTHON_ENGINE_DIR);
58
+ await mountReadOnly(py, "/vendor", VENDOR_DIR);
59
+ const wheels = await listWheels();
60
+ if (wheels.length === 0) {
61
+ throw new Error(
62
+ `No vendored wheels found in ${VENDOR_DIR}. Run "npm run vendor" to download them.`
63
+ );
64
+ }
65
+ const wheelPaths = JSON.stringify(wheels.map((w) => `/vendor/${w}`));
66
+ await mountReadOnly(py, "/in", entryDir);
67
+ await mountReadOnly(py, "/out", outAbs);
68
+ const argv = JSON.stringify(["--entry", `/in/${entryName}`, "--out-dir", "/out"]);
69
+ await py.runPythonAsync(`
70
+ import sys, os, zipfile
71
+
72
+ site_dir = '/site-packages'
73
+ if not os.path.isdir(site_dir):
74
+ os.makedirs(site_dir, exist_ok=True)
75
+ for wheel in ${wheelPaths}:
76
+ with zipfile.ZipFile(wheel) as zf:
77
+ zf.extractall(site_dir)
78
+
79
+ if site_dir not in sys.path:
80
+ sys.path.insert(0, site_dir)
81
+ if '/engine' not in sys.path:
82
+ sys.path.insert(0, '/engine')
83
+
84
+ from generate.__main__ import main
85
+ main(${argv})
86
+ `);
87
+ const f = fs(py);
88
+ f.unmount("/in");
89
+ f.unmount("/out");
90
+ f.rmdir("/in");
91
+ f.rmdir("/out");
92
+ }
93
+
94
+ // src/cli/scaffold.ts
95
+ import { existsSync } from "fs";
96
+ import { readdir as readdir2, mkdir as mkdir2, readFile, writeFile, rm } from "fs/promises";
97
+ import { join, resolve as resolve3 } from "path";
98
+ function dialecteIdFromPackageName(packageName) {
99
+ const last = packageName.split("/").pop() ?? packageName;
100
+ return last.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase();
101
+ }
102
+ function buildReplacements(options) {
103
+ const dialecteId = dialecteIdFromPackageName(options.packageName);
104
+ const DialecteName = dialecteId.charAt(0).toUpperCase() + dialecteId.slice(1);
105
+ const DIALECTE_NAME = dialecteId.toUpperCase();
106
+ return {
107
+ packageName: options.packageName,
108
+ dialecteId,
109
+ DialecteName,
110
+ DIALECTE_NAME,
111
+ version: options.version,
112
+ namespaceUri: options.namespaceUri,
113
+ coreVersion: options.coreVersion
114
+ };
115
+ }
116
+ function applyReplacements(text, r) {
117
+ return text.replaceAll("__packageName__", r.packageName).replaceAll("__DIALECTE_NAME__", r.DIALECTE_NAME).replaceAll("__DialecteName__", r.DialecteName).replaceAll("__dialecteId__", r.dialecteId).replaceAll("__version__", r.version).replaceAll("__namespaceUri__", r.namespaceUri).replaceAll("__coreVersion__", r.coreVersion);
118
+ }
119
+ function applyPathReplacements(path, r) {
120
+ const replaced = path.replaceAll("__version__", r.version);
121
+ return replaced.replace(/(^|\/)_gitignore$/, "$1.gitignore");
122
+ }
123
+ async function copyTemplateTree(srcDir, destDir, r) {
124
+ const entries = await readdir2(srcDir, { withFileTypes: true });
125
+ await mkdir2(destDir, { recursive: true });
126
+ for (const entry of entries) {
127
+ const srcPath = join(srcDir, entry.name);
128
+ const destName = applyPathReplacements(entry.name, r);
129
+ const destPath = join(destDir, destName);
130
+ if (entry.isDirectory()) {
131
+ await copyTemplateTree(srcPath, destPath, r);
132
+ continue;
133
+ }
134
+ if (entry.name === ".gitkeep") continue;
135
+ const raw = await readFile(srcPath, "utf8");
136
+ await writeFile(destPath, applyReplacements(raw, r), "utf8");
137
+ }
138
+ }
139
+ async function scaffoldDialecte(options) {
140
+ const targetDir = resolve3(options.targetDir);
141
+ const templateRoot = join(TEMPLATES_DIR, "dialecte");
142
+ if (existsSync(targetDir)) {
143
+ const remaining = await readdir2(targetDir);
144
+ if (remaining.length > 0) {
145
+ throw new Error(`Target directory is not empty: ${targetDir}`);
146
+ }
147
+ }
148
+ const replacements = buildReplacements(options);
149
+ console.log(`Scaffolding ${options.packageName} -> ${targetDir}`);
150
+ await copyTemplateTree(templateRoot, targetDir, replacements);
151
+ const definitionDir = join(targetDir, "src", options.version, "definition");
152
+ console.log(`Generating definitions from ${options.entry}`);
153
+ await runGenerator({ entry: options.entry, outDir: definitionDir });
154
+ await rm(join(definitionDir, ".gitkeep"), { force: true });
155
+ console.log("");
156
+ console.log("Done. Next steps:");
157
+ console.log(` cd ${options.targetDir}`);
158
+ console.log(" npm install");
159
+ console.log(" npm run build");
160
+ }
161
+
162
+ // src/cli/index.ts
163
+ var DEFAULT_CORE_VERSION = "^0.2.19";
164
+ var DEFAULT_VERSION = "v1";
165
+ function parseArgs(argv) {
166
+ const _ = [];
167
+ const flags = {};
168
+ for (let i = 0; i < argv.length; i++) {
169
+ const arg = argv[i];
170
+ if (arg.startsWith("--")) {
171
+ const key = arg.slice(2);
172
+ const next = argv[i + 1];
173
+ if (next === void 0 || next.startsWith("--")) {
174
+ flags[key] = true;
175
+ } else {
176
+ flags[key] = next;
177
+ i++;
178
+ }
179
+ } else {
180
+ _.push(arg);
181
+ }
182
+ }
183
+ return { _, flags };
184
+ }
185
+ function printHelp() {
186
+ console.log(`@dialecte/create - scaffold and generate Dialecte SDKs from XSD
187
+
188
+ Usage:
189
+ create-dialecte create <schema.xsd> [options] Scaffold a new dialecte package
190
+ create-dialecte generate [options] Generate definition files only
191
+ create-dialecte <schema.xsd> Shorthand for "create"
192
+
193
+ create options:
194
+ --name <pkg> npm package name (default: @dialecte/<schema basename>)
195
+ --out <dir> target directory (default: ./<dialecte id>)
196
+ --version <vN> version folder name (default: ${DEFAULT_VERSION})
197
+ --namespace <uri> default XML namespace URI (default: derived placeholder)
198
+ --core-version <ver> @dialecte/core version range (default: ${DEFAULT_CORE_VERSION})
199
+
200
+ generate options:
201
+ --entry <schema.xsd> entry XSD file (required)
202
+ --out-dir <dir> output directory for generated .ts files (required)
203
+
204
+ -h, --help show this help
205
+ `);
206
+ }
207
+ async function runGenerateCommand(flags) {
208
+ const entry = typeof flags.entry === "string" ? flags.entry : void 0;
209
+ const outDir = typeof flags["out-dir"] === "string" ? flags["out-dir"] : void 0;
210
+ if (!entry || !outDir) {
211
+ throw new Error("generate requires --entry <schema.xsd> and --out-dir <dir>");
212
+ }
213
+ if (!existsSync2(resolve4(entry))) {
214
+ throw new Error(`XSD file not found: ${entry}`);
215
+ }
216
+ await runGenerator({ entry, outDir });
217
+ }
218
+ async function runCreateCommand(positionals, flags) {
219
+ const entry = positionals[0];
220
+ if (!entry) {
221
+ throw new Error("create requires a path to an XSD schema: create-dialecte create <schema.xsd>");
222
+ }
223
+ if (!existsSync2(resolve4(entry))) {
224
+ throw new Error(`XSD file not found: ${entry}`);
225
+ }
226
+ const schemaBase = basename2(entry).replace(/\.xsd$/i, "");
227
+ const packageName = typeof flags.name === "string" ? flags.name : `@dialecte/${schemaBase.toLowerCase()}`;
228
+ const dialecteId = dialecteIdFromPackageName(packageName);
229
+ const version = typeof flags.version === "string" ? flags.version : DEFAULT_VERSION;
230
+ const namespaceUri = typeof flags.namespace === "string" ? flags.namespace : `urn:dialecte:${dialecteId}`;
231
+ const coreVersion = typeof flags["core-version"] === "string" ? flags["core-version"] : DEFAULT_CORE_VERSION;
232
+ const targetDir = typeof flags.out === "string" ? flags.out : positionals[1] ?? `./${dialecteId}`;
233
+ await scaffoldDialecte({
234
+ entry,
235
+ targetDir,
236
+ packageName,
237
+ version,
238
+ namespaceUri,
239
+ coreVersion
240
+ });
241
+ if (typeof flags.namespace !== "string") {
242
+ console.log("");
243
+ console.log(
244
+ `Note: default namespace set to "${namespaceUri}". Update src/${version}/config/namespaces.ts if needed.`
245
+ );
246
+ }
247
+ }
248
+ async function main(argv = process.argv.slice(2)) {
249
+ const { _, flags } = parseArgs(argv);
250
+ if (flags.help || flags.h || _.length === 0 && Object.keys(flags).length === 0) {
251
+ printHelp();
252
+ return;
253
+ }
254
+ const command = _[0];
255
+ if (command === "generate") {
256
+ await runGenerateCommand(flags);
257
+ return;
258
+ }
259
+ if (command === "create") {
260
+ await runCreateCommand(_.slice(1), flags);
261
+ return;
262
+ }
263
+ if (command && command.toLowerCase().endsWith(".xsd")) {
264
+ await runCreateCommand(_, flags);
265
+ return;
266
+ }
267
+ printHelp();
268
+ throw new Error(`Unknown command: ${command ?? "(none)"}`);
269
+ }
270
+ var invokedDirectly = process.argv[1] !== void 0 && resolve4(process.argv[1]) === fileURLToPath2(import.meta.url);
271
+ if (invokedDirectly) {
272
+ main().catch((err) => {
273
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
274
+ process.exit(1);
275
+ });
276
+ }
277
+ export {
278
+ main,
279
+ runGenerator,
280
+ scaffoldDialecte
281
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@dialecte/create",
3
+ "version": "0.0.1",
4
+ "description": "Scaffold and generate Dialecte SDKs from an XSD schema. Runs the Python generator in WebAssembly - no Python required.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dialecte/create.git"
9
+ },
10
+ "type": "module",
11
+ "bin": {
12
+ "create-dialecte": "./dist/cli/index.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "templates",
17
+ "vendor",
18
+ "python/generate",
19
+ "python/pyproject.toml",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev": "tsup --watch",
28
+ "start": "node dist/cli/index.js",
29
+ "vendor": "node scripts/vendor-wheels.mjs",
30
+ "test:py": "cd python && python -m pytest",
31
+ "test:cli": "node scripts/smoke.mjs",
32
+ "prepack": "find python -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null || true",
33
+ "prepublishOnly": "npm run vendor && npm run build"
34
+ },
35
+ "dependencies": {
36
+ "pyodide": "^0.27.2"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.0.0",
40
+ "tsup": "^8.5.1",
41
+ "typescript": "^5.6.0"
42
+ }
43
+ }
@@ -0,0 +1 @@
1
+ # XSD to TypeScript generation package
@@ -0,0 +1,103 @@
1
+ """CLI entry point: python -m generate --entry <xsd> --out-dir <dir>"""
2
+ import argparse
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import xmlschema
7
+
8
+ from generate.collector import collect
9
+ from generate.deriver import derive_graph, derive_identity_fields, derive_root_element, derive_singleton_elements
10
+ from generate.emitters.constants import emit_constants
11
+ from generate.emitters.definition import emit_definition
12
+ from generate.emitters.types import emit_types
13
+ from generate.globals import inject_mapped_attributes, load_attr_mapping
14
+ from generate.orphans import detect_orphans, inject_orphan_parents, load_parent_mapping
15
+ from generate.xsi_type import XsiTypeExpander
16
+ def main(argv: list[str] | None = None) -> None:
17
+ parser = argparse.ArgumentParser(
18
+ description='Generate TypeScript definition files from XSD schemas.',
19
+ )
20
+ parser.add_argument(
21
+ '--entry',
22
+ type=Path,
23
+ required=True,
24
+ help='Path to the entry XSD file (e.g. IEC61850-6-100.xsd)',
25
+ )
26
+ parser.add_argument(
27
+ '--out-dir',
28
+ type=Path,
29
+ required=True,
30
+ help='Output directory for generated .ts files',
31
+ )
32
+ args = parser.parse_args(argv)
33
+
34
+ entry: Path = args.entry
35
+ out_dir: Path = args.out_dir
36
+
37
+ if not entry.exists():
38
+ print(f'Error: XSD file not found: {entry}', file=sys.stderr)
39
+ sys.exit(1)
40
+
41
+ out_dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ # Phase 1: Parse
44
+ print(f'Loading schema: {entry}')
45
+ schema = xmlschema.XMLSchema(str(entry))
46
+
47
+ # Phase 2: Collect
48
+ print('Collecting elements...')
49
+ expander = XsiTypeExpander(schema)
50
+ elements = collect(schema, expander=expander)
51
+ print(f' Found {len(elements)} elements')
52
+
53
+ # Phase 2b: Orphan injection
54
+ mapping = load_parent_mapping(entry)
55
+ # Determine root before injection so orphans don't compete
56
+ roots = sorted(n for n, e in elements.items() if not e.parents)
57
+ root_candidate = max(roots, key=lambda n: len(elements[n].children)) if roots else None
58
+
59
+ orphans_injected = 0
60
+ unmapped_count = 0
61
+ if mapping:
62
+ orphans = detect_orphans(elements, root_candidate or '')
63
+ if orphans:
64
+ unmapped = inject_orphan_parents(elements, mapping, root_name=root_candidate or '')
65
+ orphans_injected = len(orphans) - len(unmapped)
66
+ unmapped_count = len(unmapped)
67
+ for name in unmapped:
68
+ print(f' WARNING: unmapped orphan element: {name}', file=sys.stderr)
69
+
70
+ # Phase 2c: Mapped attribute injection (attribute-mapping.json sidecar)
71
+ attr_mapping = load_attr_mapping(entry)
72
+ mapped_attrs_injected = inject_mapped_attributes(schema, elements, attr_mapping)
73
+ if mapped_attrs_injected:
74
+ print(f' Mapped attributes injected: {mapped_attrs_injected}')
75
+
76
+ # Phase 3: Derive
77
+ print('Deriving graphs...')
78
+ descendants, ancestors = derive_graph(elements)
79
+ root_element = derive_root_element(elements, override=root_candidate)
80
+ singleton_elements = derive_singleton_elements(elements, root_element)
81
+ identity_fields = derive_identity_fields(elements)
82
+ for name, fields in identity_fields.items():
83
+ elements[name].identity_fields = fields
84
+ print(f' Root: {root_element}')
85
+ print(f' Singletons: {len(singleton_elements)}')
86
+
87
+ # Phase 4: Emit
88
+ def_path = out_dir / 'definition.generated.ts'
89
+ const_path = out_dir / 'constants.generated.ts'
90
+ types_path = out_dir / 'types.generated.ts'
91
+
92
+ print('Emitting files...')
93
+ emit_definition(elements, def_path)
94
+ emit_constants(elements, descendants, ancestors, root_element, singleton_elements, const_path)
95
+ emit_types(elements, types_path)
96
+
97
+ print(f' {def_path}')
98
+ print(f' {const_path}')
99
+ print(f' {types_path}')
100
+ warnings = f', {unmapped_count} unmapped warnings' if unmapped_count else ', 0 unmapped warnings'
101
+ print(f'Done. {len(elements)} elements, {orphans_injected} orphans injected{warnings}, ROOT={root_element}')
102
+ if __name__ == '__main__':
103
+ main()
@@ -0,0 +1,128 @@
1
+ """Collector — recursive walk of XSD schema into ElementDef IR.
2
+
3
+ Phase 2 of the pipeline: Parse → **Collect** → Derive → Emit.
4
+ """
5
+ from typing import Any
6
+
7
+ from generate.extractors.attributes import extract_attributes
8
+ from generate.extractors.children import extract_children, extract_choices, extract_text_content, iter_child_elements
9
+ from generate.extractors.constraints import extract_constraints
10
+ from generate.extractors.docs import extract_docs
11
+ from generate.extractors.namespace import extract_namespace
12
+ from generate.ir import ElementDef
13
+ from generate.xsi_type import XsiTypeExpander
14
+ def collect(schema: Any, expander: XsiTypeExpander | None = None) -> dict[str, ElementDef]:
15
+ """Walk all elements across all schemas (root + imported/included) into a flat IR dict.
16
+
17
+ When *expander* is provided, abstract-typed element slots are additionally enriched
18
+ with their ``xsi:type`` substitution variants (children/attributes unioned in, and
19
+ variant-only children walked for recursion). When omitted, behaviour is unchanged.
20
+
21
+ xmlschema API:
22
+ XMLSchemaBase.elements: NamespaceView — global elements of this schema
23
+ XMLSchemaBase.includes: dict[str, XMLSchemaBase] — included schemas
24
+ XMLSchemaBase.imports: dict[str, XMLSchemaBase | None] — imported schemas
25
+ """
26
+ elements: dict[str, ElementDef] = {}
27
+
28
+ def walk(xsd_elem: Any, parent_name: str | None = None, visited: set[int] | None = None) -> None:
29
+ if visited is None:
30
+ visited = set()
31
+
32
+ name = getattr(xsd_elem, 'local_name', None)
33
+ if not name:
34
+ return
35
+
36
+ # Already seen this element by tag name — always add parent link first,
37
+ # then id-guard the recursion to prevent infinite loops.
38
+ if name in elements:
39
+ existing = elements[name]
40
+ if parent_name and parent_name not in existing.parents:
41
+ existing.parents.append(parent_name)
42
+ if expander is not None:
43
+ expander.expand(
44
+ xsd_elem,
45
+ existing.attr_sequence,
46
+ existing.attributes,
47
+ existing.child_sequence,
48
+ existing.children,
49
+ )
50
+ elem_id = id(xsd_elem)
51
+ if elem_id in visited:
52
+ return
53
+ visited.add(elem_id)
54
+ # Still recurse into children to discover deeper elements
55
+ for child_xsd in iter_child_elements(xsd_elem):
56
+ walk(child_xsd, parent_name=name, visited=visited)
57
+ if expander is not None:
58
+ for child_xsd in expander.iter_variant_child_elements(xsd_elem):
59
+ walk(child_xsd, parent_name=name, visited=visited)
60
+ return
61
+
62
+ elem_id = id(xsd_elem)
63
+ if elem_id in visited:
64
+ return
65
+ visited.add(elem_id)
66
+
67
+ attr_seq, attr_any, attrs = extract_attributes(xsd_elem)
68
+ child_seq, child_any, children = extract_children(xsd_elem)
69
+
70
+ if expander is not None:
71
+ expander.expand(xsd_elem, attr_seq, attrs, child_seq, children)
72
+
73
+ elements[name] = ElementDef(
74
+ tag=name,
75
+ namespace=extract_namespace(xsd_elem),
76
+ documentation=extract_docs(xsd_elem),
77
+ parents=[parent_name] if parent_name else [],
78
+ attr_sequence=attr_seq,
79
+ attr_any=attr_any,
80
+ attributes=attrs,
81
+ child_sequence=child_seq,
82
+ child_any=child_any,
83
+ children=children,
84
+ choices=extract_choices(xsd_elem),
85
+ constraints=extract_constraints(xsd_elem),
86
+ text_content=extract_text_content(xsd_elem),
87
+ )
88
+
89
+ for child_xsd in iter_child_elements(xsd_elem):
90
+ walk(child_xsd, parent_name=name, visited=visited)
91
+ if expander is not None:
92
+ for child_xsd in expander.iter_variant_child_elements(xsd_elem):
93
+ walk(child_xsd, parent_name=name, visited=visited)
94
+
95
+ # Walk imported/included schemas FIRST so standard elements (e.g. scl:LNode)
96
+ # are registered with their canonical namespace before the root extension
97
+ # schema re-declares them as local elements (e.g. eIEC61850-6-100:LNode).
98
+ for sub_schema in _iter_sub_schemas(schema):
99
+ for root_elem in sub_schema.elements.values():
100
+ walk(root_elem)
101
+
102
+ # Walk root schema's global elements (adds extension elements + parent links)
103
+ for root_elem in schema.elements.values():
104
+ walk(root_elem)
105
+
106
+ return elements
107
+ def _iter_sub_schemas(schema: Any):
108
+ """Yield all imported and included schemas.
109
+
110
+ xmlschema API:
111
+ XMLSchemaBase.includes: dict[str, XMLSchemaBase]
112
+ XMLSchemaBase.imports: dict[str, XMLSchemaBase | None]
113
+ """
114
+ seen: set[int] = set()
115
+
116
+ includes = getattr(schema, 'includes', None) or {}
117
+ if hasattr(includes, 'values'):
118
+ for included in includes.values():
119
+ if included is not None and id(included) not in seen:
120
+ seen.add(id(included))
121
+ yield included
122
+
123
+ imports = getattr(schema, 'imports', None) or {}
124
+ if hasattr(imports, 'values'):
125
+ for imported in imports.values():
126
+ if imported is not None and id(imported) not in seen:
127
+ seen.add(id(imported))
128
+ yield imported