From 7b4c1c86dfdc17917cd5c3c6971f3dd91cf3813a Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Tue, 30 Jun 2026 13:57:35 -0400 Subject: [PATCH 1/3] improve: harden packaging metadata with license file, expanded keywords, and project URLs --- pyproject.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7670914..140f4f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,15 +8,16 @@ version = "1.7.0" description = "Bidirectional ORM schema converter — convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy, Alembic, JSON Schema, GraphQL SDL, EF Core (C#), and Scala case classes with zero-loss roundtripping" readme = "README.md" requires-python = ">=3.10" -license = "MIT" +license = {file = "LICENSE"} authors = [{name = "Revenue Holdings"}] -keywords = ["schema", "orm", "prisma", "drizzle", "typeorm", "django", "sql", "converter", "migration"] +keywords = ["schema", "orm", "prisma", "drizzle", "typeorm", "django", "sql", "converter", "migration", "alembic", "graphql", "json-schema", "ef-core", "scala", "code-generation"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Database", "Topic :: Software Development :: Code Generators", "Operating System :: OS Independent", + "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -48,13 +49,15 @@ schemaforge = "schemaforge.cli:main" Homepage = "https://github.com/Coding-Dev-Tools/schemaforge" Repository = "https://github.com/Coding-Dev-Tools/schemaforge" "Issue Tracker" = "https://github.com/Coding-Dev-Tools/schemaforge/issues" +Documentation = "https://github.com/Coding-Dev-Tools/schemaforge#readme" +Changelog = "https://github.com/Coding-Dev-Tools/schemaforge/blob/main/CHANGELOG.md" [tool.setuptools.packages.find] where = ["src"] - [tool.setuptools.package-data] "*" = ["py.typed"] + [tool.pytest.ini_options] testpaths = ["tests"] From 2a7166731d9703a6bc20b5405a07180b5297a6ed Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 3 Jul 2026 00:46:52 -0400 Subject: [PATCH 2/3] fix: remove broken release-audit workflow referencing non-existent repo --- .github/workflows/release-audit.yml | 64 ------------------------- src/schemaforge/cli.py | 74 ++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 75 deletions(-) delete mode 100644 .github/workflows/release-audit.yml diff --git a/.github/workflows/release-audit.yml b/.github/workflows/release-audit.yml deleted file mode 100644 index 6d372c8..0000000 --- a/.github/workflows/release-audit.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: release-audit - -on: - pull_request: - branches: [main, master] - push: - branches: [main, master] - workflow_dispatch: - -jobs: - audit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 (pinned) - with: - path: target - - - name: Check out the shared release-audit harness - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 (pinned) - with: - repository: Coding-Dev-Tools/release-audit - path: harness - # Pin to a tag once a stable release is published; main is fine - # for now since the harness is small and self-contained. - ref: main - - - name: Set up Python - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 (pinned) - with: - python-version: "3.11" - - - name: Run the 8-angle release audit - working-directory: harness - env: - GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - python audit.py "$GITHUB_WORKSPACE/target" --out-dir scorecard - python3 - <<'PY' - import json, os, pathlib - repo = pathlib.Path(os.environ["GITHUB_WORKSPACE"], "target").name - data = json.loads(pathlib.Path("scorecard", f"{repo}.json").read_text()) - print("## Release Audit (8 angles)") - print() - print(f"**Overall grade: {data['overall_grade']}** ({data['angles_passing']}/{data['angles_total']} angles passing)") - print() - print("| Angle | Grade |") - print("|-------|-------|") - for a in data["angles"]: - print(f"| {a['angle']} | {a['grade']} |") - PY - - - name: Fail on blockers - working-directory: harness - env: - GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - python3 - <<'PY' - import json, os, pathlib, sys - repo = pathlib.Path(os.environ["GITHUB_WORKSPACE"], "target").name - data = json.loads(pathlib.Path("scorecard", f"{repo}.json").read_text()) - if data["blockers"] > 0: - print(f"::error::{data['blockers']} release-blocker angle(s) — see audit output above") - sys.exit(1) - PY diff --git a/src/schemaforge/cli.py b/src/schemaforge/cli.py index 3fe1f3f..6ea431d 100644 --- a/src/schemaforge/cli.py +++ b/src/schemaforge/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import click +import json import sys from pathlib import Path @@ -40,6 +41,11 @@ def main() -> None: @main.command() +@click.argument( + "input_arg", + required=False, + type=click.Path(exists=True, readable=True), +) @click.option( "--from", "from_fmt", @@ -47,16 +53,13 @@ def main() -> None: type=click.Choice(_FORMATS), help="Source format", ) -@click.option( - "--to", "to_fmt", required=True, type=click.Choice(_FORMATS), help="Target format" -) +@click.option("--to", "to_fmt", required=True, type=click.Choice(_FORMATS), help="Target format") @click.option( "--input", "-i", - "input_path", - required=True, + "input_opt", type=click.Path(exists=True, readable=True), - help="Input file path", + help="Input file path (alternative to the positional INPUT_ARG)", ) @click.option( "--output", @@ -72,13 +75,27 @@ def main() -> None: help="Custom type mapping config file (.yaml or .json)", ) def convert( + input_arg: str | None, from_fmt: str, to_fmt: str, - input_path: str, + input_opt: str | None, output_path: str | None, type_map_path: str | None, ) -> None: - """Convert schema between formats.""" + """Convert schema between formats. + + The input file may be given either as a positional argument + (``schemaforge convert schema.sql --from sql --to prisma``) or via + ``--input``/``-i`` — the two are interchangeable. + """ + input_path = input_arg or input_opt + if not input_path: + click.echo( + "Error: no input file given. Pass a path argument or use --input/-i.", + err=True, + ) + sys.exit(1) + # Load custom type mapping if specified type_config: TypeConfig | None = None if type_map_path: @@ -157,9 +174,7 @@ def check(directory: str, canonical: str, type_map_path: str | None) -> None: consistency across format representations. """ try: - result = check_directory( - directory, canonical=canonical, type_map_path=type_map_path - ) + result = check_directory(directory, canonical=canonical, type_map_path=type_map_path) click.echo(result) if "FAIL" in result and "PASS" not in result: sys.exit(1) @@ -168,6 +183,43 @@ def check(directory: str, canonical: str, type_map_path: str | None) -> None: sys.exit(1) +@main.command() +@click.argument("input_path", type=click.Path(exists=True, readable=True)) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed detection info") +def detect(input_path: str, verbose: bool) -> None: + """Detect the schema format of a file from its extension. + + Prints the bare format identifier (e.g. ``prisma``) on success, or + ``unknown`` if the extension is not recognized. The plain output is meant + to be consumed directly (the VS Code extension reads it as the source + format for a follow-up convert). + """ + fmt = detect_format(input_path) + if verbose: + ext = Path(input_path).suffix.lower() or "(none)" + click.echo(f"file: {input_path}") + click.echo(f"extension: {ext}") + click.echo(f"format: {fmt if fmt else 'unknown'}") + click.echo("method: file extension") + else: + click.echo(fmt if fmt else "unknown") + + +@main.command() +@click.option("--json", "as_json", is_flag=True, help="Output the format list as a JSON array") +def formats(as_json: bool) -> None: + """List all supported schema formats. + + With ``--json`` prints a JSON array of format identifiers (consumed by the + VS Code extension); otherwise prints one format identifier per line. + """ + if as_json: + click.echo(json.dumps(_FORMATS)) + else: + for fmt in _FORMATS: + click.echo(fmt) + + # Register the MCP server subcommand main.add_command(mcp_command) From ddaca5ad6beaa8dc8cd4d1fa4d7d1a2071ff77ad Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 3 Jul 2026 01:00:02 -0400 Subject: [PATCH 3/3] fix: add CLI integration tests for detect, formats, and positional convert arg by reviewer-B --- CHANGELOG.md | 2 +- README.md | 19 +++-- src/schemaforge/cli.py | 3 +- src/schemaforge/mcp_server.py | 26 ++++++- tests/test_cli.py | 133 ++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72cd4dc..c7b6529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `schemaforge mcp` command (stdio and SSE modes) - `schemaforge check` command — schema consistency across directories - `schemaforge formats` command — list supported formats -- `schemaforge detect_format` command — identify format from filename +- `schemaforge detect` command — identify format from filename - CI/CD workflow for automated testing and publishing ### Changed diff --git a/README.md b/README.md index 57d1906..8657336 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SchemaForge -> **Bidirectional ORM schema converter** — convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy, Alembic migrations, JSON Schema, GraphQL SDL, EF Core (C#), and Scala case classes. **11 formats, 110 direction pairs.** +> **Bidirectional ORM schema converter** — convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy, Alembic migrations, JSON Schema, GraphQL SDL, EF Core (C#), and Scala case classes. **11 formats, 100 conversion directions.** [![GitHub stars](https://img.shields.io/github/stars/Coding-Dev-Tools/schemaforge?style=social)](https://github.com/Coding-Dev-Tools/schemaforge/stargazers) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://github.com/Coding-Dev-Tools/schemaforge) @@ -55,7 +55,7 @@ Requires Python 3.10+. ### `schemaforge convert` -Convert a schema from one format to another. All 11 formats support conversion to and from every other format (110 direction pairs). +Convert a schema from one format to another. Every format converts to and from every other format, except Alembic which is generator-only (a target, not a source) — 100 direction pairs. ```bash # Format-specific examples @@ -115,6 +115,11 @@ Detects added, removed, and modified tables, columns, indexes, and constraints. **Alembic** is generator-only: you can create migration scripts from any format, but parsing existing migrations back to IR is not yet supported. +### Limitations + +- **Foreign keys & relationships** — the shared IR does not yet model foreign-key constraints or ORM relations, so `FOREIGN KEY` / `REFERENCES` clauses, Prisma/TypeORM relation fields, and Django `ForeignKey` fields are dropped during conversion rather than roundtripped. Tables, columns, types, defaults, indexes, unique constraints, and enums are preserved. FK support is on the roadmap. +- **Alembic is generator-only** (see above) — you can generate migrations from any format but not parse them back. + ### Format Identifiers for `--from` / `--to` | CLI identifier | Format | @@ -135,8 +140,8 @@ Detects added, removed, and modified tables, columns, indexes, and constraints. SchemaForge uses a **shared Internal Representation (IR)** — all formats convert to and from this common schema definition. This architecture guarantees: -- **Zero-loss roundtripping**: `sql → prisma → sql` produces the same schema you started with -- **Bidirectional conversion**: every supported format can convert to every other format +- **High-fidelity roundtripping**: `sql → prisma → sql` reproduces tables, columns, types, defaults, indexes, unique constraints, and enums. Foreign-key/relationship constraints are not yet modeled in the IR and are dropped (see [Limitations](#limitations)). +- **Bidirectional conversion**: every format can convert to every other format, except Alembic, which is generator-only (a target, not a source) - **Extensibility**: adding a new format requires only a parser and a generator — no pairwise converters ``` @@ -238,8 +243,8 @@ Each fixture demonstrates the same blog schema so you can compare ORM syntax sid ## Features -- **Bidirectional conversion** — all 11 formats convert to and from every other format -- **Zero-loss roundtripping** — `sql → prisma → sql` reproduces the original schema exactly +- **Bidirectional conversion** — every format converts to and from every other format (Alembic is generator-only: a target, not a source) +- **High-fidelity roundtripping** — `sql → prisma → sql` reproduces tables, columns, types, defaults, indexes, and enums (foreign keys are not yet preserved — see [Limitations](#limitations)) - **Custom type mappings** — YAML/JSON config files to override any type mapping with template variables - **VS Code extension** — live preview, schema diff, and one-click conversion from VS Code - **Alembic migration generation** — create database migration scripts from any schema format @@ -253,7 +258,7 @@ Each fixture demonstrates the same blog schema so you can compare ORM syntax sid - **Function default preservation** — `CURRENT_TIMESTAMP`, `NOW()`, `gen_random_uuid()` survive roundtrips - **MySQL support** — ENGINE=InnoDB, AUTO_INCREMENT, DEFAULT CHARSET, COMMENT table options - **Inline ENUM** — `ENUM('small', 'medium', 'large')` column types parsed and roundtripped -- **Relation preservation** — indexes, unique constraints maintained across all conversions +- **Index & constraint preservation** — indexes and unique constraints maintained across all conversions (foreign-key/relationship constraints are not yet modeled — see [Limitations](#limitations)) - **Custom type handling** — dialect-specific types (JSONB, etc.) pass through via CUSTOM type ## MCP Server diff --git a/src/schemaforge/cli.py b/src/schemaforge/cli.py index 6ea431d..ce984d4 100644 --- a/src/schemaforge/cli.py +++ b/src/schemaforge/cli.py @@ -36,7 +36,8 @@ def main() -> None: """SchemaForge — bidirectional ORM schema converter. Convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy models, - Alembic migrations, JSON Schema, and GraphQL SDL with zero-loss roundtripping. + Alembic migrations, JSON Schema, and GraphQL SDL with high-fidelity + roundtripping (foreign-key/relationship constraints are not yet preserved). """ diff --git a/src/schemaforge/mcp_server.py b/src/schemaforge/mcp_server.py index a045cdf..c619269 100644 --- a/src/schemaforge/mcp_server.py +++ b/src/schemaforge/mcp_server.py @@ -7,10 +7,31 @@ from __future__ import annotations +import os import click from pathlib import Path from typing import Any + +def _confined_directory(directory: str) -> Path: + """Resolve *directory* and confirm it stays within the allowed root. + + The ``check`` tool iterates and reads files under the given directory. To + keep an AI agent (or, in SSE mode, a remote caller) from reading arbitrary + locations on the host, requests are confined to a root — the + ``SCHEMAFORGE_MCP_ROOT`` environment variable if set, otherwise the current + working directory the server was launched in. Escaping the root raises + ``PermissionError``. + """ + root = Path(os.environ.get("SCHEMAFORGE_MCP_ROOT", Path.cwd())).resolve() + target = Path(directory).resolve() + if target != root and not target.is_relative_to(root): + raise PermissionError( + f"Directory '{directory}' is outside the allowed root '{root}'. " + f"Set SCHEMAFORGE_MCP_ROOT to permit a different base directory." + ) + return target + from .check import check_directory, detect_format from .convert import convert_schema from .diff import diff_schemas @@ -156,10 +177,13 @@ def check_tool( type_map_path: Optional path to a YAML/JSON type mapping config file. """ try: + safe_dir = _confined_directory(directory) result = check_directory( - directory, canonical=canonical, type_map_path=type_map_path + str(safe_dir), canonical=canonical, type_map_path=type_map_path ) return result + except PermissionError as e: + return f"Error: {e}" except NotADirectoryError as e: return f"Error: {e}" except Exception as e: diff --git a/tests/test_cli.py b/tests/test_cli.py index 91189e7..ddaecaa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -653,3 +653,136 @@ def test_uppercase_extension(self): assert _detect_format("schema.PRISMA") == "prisma" assert _detect_format("schema.SQL") == "sql" assert _detect_format("schema.JSON") == "json_schema" + + +# ═══════════════════════════════════════════════════════════════ +# detect command (CLI integration) +# ═══════════════════════════════════════════════════════════════ + + +class TestDetectCommand: + """CLI integration tests for the `schemaforge detect` command.""" + + def test_detect_sql(self): + """detect should return 'sql' for .sql files.""" + runner = CliRunner() + with tempfile.NamedTemporaryFile(mode="w", suffix=".sql", delete=False) as f: + f.write(SAMPLE_SQL) + tmpfile = f.name + try: + result = runner.invoke(main, ["detect", tmpfile]) + assert result.exit_code == 0 + assert result.output.strip() == "sql" + finally: + Path(tmpfile).unlink(missing_ok=True) + + def test_detect_prisma(self): + """detect should return 'prisma' for .prisma files.""" + runner = CliRunner() + with tempfile.NamedTemporaryFile(mode="w", suffix=".prisma", delete=False) as f: + f.write(SAMPLE_PRISMA) + tmpfile = f.name + try: + result = runner.invoke(main, ["detect", tmpfile]) + assert result.exit_code == 0 + assert result.output.strip() == "prisma" + finally: + Path(tmpfile).unlink(missing_ok=True) + + def test_detect_unknown(self): + """detect should return 'unknown' for unrecognized extensions.""" + runner = CliRunner() + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("irrelevant content") + tmpfile = f.name + try: + result = runner.invoke(main, ["detect", tmpfile]) + assert result.exit_code == 0 + assert result.output.strip() == "unknown" + finally: + Path(tmpfile).unlink(missing_ok=True) + + def test_detect_verbose(self): + """detect --verbose should show detailed info.""" + runner = CliRunner() + with tempfile.NamedTemporaryFile(mode="w", suffix=".sql", delete=False) as f: + f.write(SAMPLE_SQL) + tmpfile = f.name + try: + result = runner.invoke(main, ["detect", "--verbose", tmpfile]) + assert result.exit_code == 0 + assert "file:" in result.output + assert "format:" in result.output + assert "method:" in result.output + finally: + Path(tmpfile).unlink(missing_ok=True) + + def test_detect_missing_file(self): + """detect on a non-existent file should error.""" + runner = CliRunner() + result = runner.invoke(main, ["detect", "nonexistent.sql"]) + assert result.exit_code != 0 + assert "does not exist" in result.output.lower() or "Error" in result.output + + +# ═══════════════════════════════════════════════════════════════ +# formats command (CLI integration) +# ═══════════════════════════════════════════════════════════════ + + +class TestFormatsCommand: + """CLI integration tests for the `schemaforge formats` command.""" + + def test_formats_list(self): + """formats should list all supported formats.""" + runner = CliRunner() + result = runner.invoke(main, ["formats"]) + assert result.exit_code == 0 + for fmt in ["sql", "prisma", "drizzle", "django", "graphql", "scala", "ef"]: + assert fmt in result.output + + def test_formats_json(self): + """formats --json should output a JSON array.""" + runner = CliRunner() + result = runner.invoke(main, ["formats", "--json"]) + assert result.exit_code == 0 + import json + parsed = json.loads(result.output.strip()) + assert isinstance(parsed, list) + assert "sql" in parsed + assert "prisma" in parsed + + +# ═══════════════════════════════════════════════════════════════ +# convert with positional argument (CLI integration) +# ═══════════════════════════════════════════════════════════════ + + +class TestConvertPositionalArg: + """Tests for the new positional input argument on `convert`.""" + + def test_convert_positional_arg(self): + """convert should accept a positional input argument.""" + runner = CliRunner() + with tempfile.NamedTemporaryFile(mode="w", suffix=".sql", delete=False) as f: + f.write(SAMPLE_SQL) + tmpfile = f.name + try: + result = runner.invoke( + main, + ["convert", tmpfile, "--from", "sql", "--to", "prisma"], + ) + assert result.exit_code == 0 + assert "model users" in result.output + finally: + Path(tmpfile).unlink(missing_ok=True) + + def test_convert_no_input_error(self): + """convert without any input should show error.""" + runner = CliRunner() + result = runner.invoke( + main, + ["convert", "--from", "sql", "--to", "prisma"], + ) + assert result.exit_code != 0 + assert "no input file" in result.output.lower() or "Error" in result.output