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/CHANGELOG.md b/CHANGELOG.md
index 72cd4dc..f5f7bb2 100644
Binary files a/CHANGELOG.md and b/CHANGELOG.md differ
diff --git a/README.md b/README.md
index 57d1906..2bc3532 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.**
[](https://github.com/Coding-Dev-Tools/schemaforge/stargazers)
[](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
@@ -442,10 +447,4 @@ MIT — see [LICENSE](LICENSE)
---
-Part of [Revenue Holdings](https://coding-dev-tools.github.io/devforge/) — a suite of 11 developer CLI tools built by autonomous AI agents. Also check out the [SchemaForge VS Code extension](https://github.com/Coding-Dev-Tools/vscode-schemaforge), [ConfigDrift](https://github.com/Coding-Dev-Tools/configdrift) (config drift detection), [DataMorph](https://github.com/Coding-Dev-Tools/datamorph) (data format conversion), [DeadCode](https://github.com/Coding-Dev-Tools/deadcode) (dead code cleanup), [DeployDiff](https://github.com/Coding-Dev-Tools/deploydiff) (infrastructure diffs), [Envault](https://github.com/Coding-Dev-Tools/envault) (env sync), [APIAuth](https://github.com/Coding-Dev-Tools/apiauth) (API key management), [APIGhost](https://github.com/Coding-Dev-Tools/apighost) (mock API server), [json2sql](https://github.com/Coding-Dev-Tools/json2sql) (JSON → SQL), [API Contract Guardian](https://github.com/Coding-Dev-Tools/api-contract-guardian) (breaking change detection), and [click-to-mcp](https://github.com/Coding-Dev-Tools/click-to-mcp) (CLI → MCP server).
-
-## Test
-
-```bash
-npm test # runs: node --test tests/
-```
+Part of [Revenue Holdings](https://coding-dev-tools.github.io/devforge/) — a s
\ No newline at end of file
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"]
diff --git a/src/schemaforge/cli.py b/src/schemaforge/cli.py
index 3fe1f3f..ce984d4 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
@@ -35,11 +36,17 @@ 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).
"""
@main.command()
+@click.argument(
+ "input_arg",
+ required=False,
+ type=click.Path(exists=True, readable=True),
+)
@click.option(
"--from",
"from_fmt",
@@ -47,16 +54,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 +76,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 +175,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 +184,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)
diff --git a/src/schemaforge/mcp_server.py b/src/schemaforge/mcp_server.py
index a045cdf..c7a7f0c 100644
--- a/src/schemaforge/mcp_server.py
+++ b/src/schemaforge/mcp_server.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import click
+import os
from pathlib import Path
from typing import Any
@@ -23,6 +24,26 @@
FastMCP = None # type: ignore
+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
+
+
# All supported formats
_FORMATS = [
"sql",
@@ -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