diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 252e7e2..5a4e7c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,12 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} @@ -44,12 +44,12 @@ jobs: schema-consistency: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -61,7 +61,3 @@ jobs: - name: Check schema consistency run: | python scripts/check_consistency.py - - - name: Run schemaforge check on fixtures - run: | - schemaforge check --dir /tmp --canonical sql || true diff --git a/.github/workflows/cowork-auto-pr.yml b/.github/workflows/cowork-auto-pr.yml index b27f04e..201c2b9 100644 --- a/.github/workflows/cowork-auto-pr.yml +++ b/.github/workflows/cowork-auto-pr.yml @@ -16,7 +16,7 @@ jobs: # without this step every run failed with "not a git repository" and no # PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it). - name: Check out the pushed branch - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.ref_name }} fetch-depth: 0 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 31993c2..21af7f3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,7 +18,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Pages diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3b3dd01..9c36b52 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,12 +23,12 @@ jobs: environment: pypi steps: - - uses: actions/checkout@v4 # v4.2.2 (pinned) + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -48,13 +48,13 @@ jobs: - name: Publish to TestPyPI if: ${{ inputs.pypi_target == 'testpypi' }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: repository-url: https://test.pypi.org/legacy/ - name: Publish to PyPI if: ${{ inputs.pypi_target == 'pypi' || github.event_name == 'release' }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 npm-publish: runs-on: ubuntu-latest @@ -62,10 +62,10 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4 # v4.2.2 (pinned) + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 registry-url: 'https://registry.npmjs.org' diff --git a/.gitignore b/.gitignore index 4bc182b..8b3ba7e 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ node_modules _audit_reqs.txt nul package-lock.json +work-log.jsonl diff --git a/fixtures/sample.alembic.py b/fixtures/sample.alembic.py index a4cf28b..8cebc2a 100644 --- a/fixtures/sample.alembic.py +++ b/fixtures/sample.alembic.py @@ -4,24 +4,26 @@ Revises: Create Date: 2026-05-15 03:00:00.000000 """ + import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision = 'sample' +revision = "sample" down_revision = None def upgrade() -> None: - op.create_table('users', - sa.Column('id', sa.Integer(), primary_key=True), - sa.Column('name', sa.String(100), nullable=False), - sa.Column('email', sa.String(255), nullable=False, unique=True), - sa.Column('role', sa.Enum('admin', 'editor', 'viewer'), nullable=False), - sa.Column('is_active', sa.Boolean(), server_default=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("email", sa.String(255), nullable=False, unique=True), + sa.Column("role", sa.Enum("admin", "editor", "viewer"), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), ) def downgrade() -> None: - op.drop_table('users') + op.drop_table("users") diff --git a/src/schemaforge/generators/json_schema_generator.py b/src/schemaforge/generators/json_schema_generator.py index 27e140a..f874f0e 100644 --- a/src/schemaforge/generators/json_schema_generator.py +++ b/src/schemaforge/generators/json_schema_generator.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import warnings from typing import Any from ..ir import Column, ColumnType, Schema, Table @@ -130,7 +131,15 @@ def _column_to_prop(self, col: Column) -> dict[str, Any]: prop = _json.loads(overridden) return self._add_base_annotations(prop, col) except (json.JSONDecodeError, ValueError): - pass + # A malformed override is a silent-failure trap: the + # generator would fall back to a plain type string with + # no indication the JSON override was discarded. + warnings.warn( + f"json_schema generator: type override for column " + f"'{col.name}' looks like JSON but failed to parse; " + f"using it as a plain type instead", + stacklevel=2, + ) prop["type"] = overridden return self._add_base_annotations(prop, col) diff --git a/src/schemaforge/parsers/sql_parser.py b/src/schemaforge/parsers/sql_parser.py index 6e40541..eae1498 100644 --- a/src/schemaforge/parsers/sql_parser.py +++ b/src/schemaforge/parsers/sql_parser.py @@ -4,6 +4,7 @@ import contextlib import re +import warnings from typing import Any from ..ir import Column, ColumnType, EnumType, Index, Schema, Table @@ -146,10 +147,17 @@ def _parse_create_table(self, stmt: str) -> Table | None: table.indexes.append(idx) elif upper.startswith("PRIMARY KEY"): pass # PK handled via column constraints - elif upper.startswith("CONSTRAINT"): - pass # Foreign keys, etc. - elif upper.startswith("FOREIGN KEY") or upper.startswith("CHECK"): - pass + elif upper.startswith("CONSTRAINT") or upper.startswith( + "FOREIGN KEY" + ) or upper.startswith("CHECK"): + # Silent drops here are a correctness trap: schemas that differ + # only in FK/CHECK constraints would compare as equivalent. + # Surface the loss instead of swallowing it. + warnings.warn( + f"SQL parser: ignored unsupported table constraint in " + f"'{table.name}': {defn[:60]}", + stacklevel=3, + ) else: col = self._parse_column_def(defn) if col: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 96add98..7547cd6 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -2,13 +2,17 @@ from __future__ import annotations -import pytest +import pytest # noqa: F401 import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -pytest.importorskip("mcp", reason="mcp is an optional dependency") +# Skip if mcp.server.fastmcp is not importable — mcp may be installed +# but FastMCP could still be unavailable (API changes, partial installs). +# The mcp_server module catches ImportError and sets FastMCP=None, so +# we must check the actual import path used by create_server(). +pytest.importorskip("mcp.server.fastmcp", reason="mcp.server.fastmcp is required for MCP server tests") from schemaforge.mcp_server import _FORMATS, create_server diff --git a/tests/test_silent_failures.py b/tests/test_silent_failures.py new file mode 100644 index 0000000..d456006 --- /dev/null +++ b/tests/test_silent_failures.py @@ -0,0 +1,86 @@ +"""Silent-failure regression tests: unsupported constructs must warn, not vanish.""" + +from __future__ import annotations + +import pytest +import warnings +from schemaforge.generators.json_schema_generator import JSONSchemaGenerator +from schemaforge.parsers.sql_parser import SQLParser +from schemaforge.type_config import TypeConfig + + +def test_sql_parser_warns_on_foreign_key_constraint() -> None: + sql = """ + CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + customer_id INTEGER, + FOREIGN KEY (customer_id) REFERENCES customers(id) + ); + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + SQLParser().parse(sql) + assert any("FOREIGN KEY" in str(w.message) for w in caught) + + +def test_sql_parser_warns_on_named_check_constraint() -> None: + sql = """ + CREATE TABLE products ( + price NUMERIC, + CONSTRAINT positive_price CHECK (price >= 0) + ); + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + SQLParser().parse(sql) + assert any("CHECK" in str(w.message) for w in caught) + + +def test_sql_parser_no_warning_for_plain_table() -> None: + sql = """ + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email VARCHAR(255) NOT NULL + ); + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + SQLParser().parse(sql) + assert not caught + + +def test_json_schema_generator_warns_on_malformed_override() -> None: + from schemaforge.ir import Column, ColumnType + + class MalformedOverrideCfg(TypeConfig): + """Returns an override that looks like JSON but fails to parse.""" + + def get_override(self, col, fmt, type_args=None): # noqa: D102 + return "{broken json}" + + col = Column(name="email", type=ColumnType.STRING, nullable=True) + gen = JSONSchemaGenerator(type_config=MalformedOverrideCfg()) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + gen._column_to_prop(col) + assert any("failed to parse" in str(w.message) for w in caught) + + +def _make_column(name: str): + from schemaforge.ir import Column, ColumnType + + return Column(name=name, type=ColumnType.STRING, nullable=True) + + +@pytest.mark.parametrize( + "sql", + [ + "CREATE TABLE t (a INT, CONSTRAINT fk_a FOREIGN KEY (a) REFERENCES o(a));", + "CREATE TABLE t (a INT, FOREIGN KEY (a) REFERENCES o(a));", + ], +) +def test_warning_includes_table_name(sql: str) -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + SQLParser().parse(sql) + assert any("'t'" in str(w.message) for w in caught)