Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 296 additions & 0 deletions scripts/check_retrofit_migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Static guard against retrofit migrations placed in MIGRATIONS (#tsk-gqsuah).

``BaseStore.init()`` runs a store's ``SCHEMA`` string (CREATE TABLE +
CREATE INDEX) BEFORE the migration runner. The runner uses
baseline-at-latest semantics: pre-existing databases are stamped at the
latest version WITHOUT executing any SQL, so any migration that ALTERs
or adds an index to a table that ALSO appears in SCHEMA is silently
skipped on exactly the databases that need it. The tracking table then
records the migration as applied -- a false success.

The correct pattern (mandated in the BaseStore docstring and
``db_migrations.py`` footgun #2) is a guarded ``_post_init`` coroutine:
``PRAGMA table_info`` check + ``ALTER TABLE`` only when the column is
absent. Such a migration runs every init, stays permanently in the
codebase, and self-checks whether it already ran.

This script statically inspects every Python file under ``tinyagentos/``
that defines a store and flags the dangerous pattern: a MIGRATIONS entry
that ALTERs or adds a CREATE INDEX to a table that ALSO appears in that
store's SCHEMA -- i.e. a retrofit to a pre-existing table that
baseline-at-latest will skip.

It does NOT flag:
- CREATE TABLE IF NOT EXISTS in MIGRATIONS (genuinely new tables are
safe: idempotent on existing DBs).
- Migrations that reference the SCHEMA constant itself (the bootstrap
pattern ``MIGRATIONS = [(1, SCHEMA)]``), since that is the initial
schema creation, not a retrofit.

Usage:
python scripts/check_retrofit_migrations.py
Prints ``retrofit-migration-guard: clean`` and exits 0 when no violations,
or prints each violation and exits 1.

Dependency-light: stdlib only (ast + re + pathlib).
"""
from __future__ import annotations

import ast
import re
import sys
from dataclasses import dataclass
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
STORES_ROOT = REPO_ROOT / "tinyagentos"

# ALTER TABLE <table> ADD [COLUMN] <col> ...
_ADD_COLUMN_RE = re.compile(
r"ALTER\s+TABLE\s+(\w+)\s+ADD\s+(?:COLUMN\s+)?(\w+)",
re.IGNORECASE,
)
Comment on lines +49 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Detect every ALTER TABLE retrofit, not only ADD COLUMN.

Line 51 ignores operations such as RENAME COLUMN, DROP COLUMN, and table renames. Those migrations are also skipped for baseline-at-latest databases, producing the same upgrade breakage this guard is intended to prevent.

Proposed direction
-_ADD_COLUMN_RE = re.compile(
-    r"ALTER\s+TABLE\s+(\w+)\s+ADD\s+(?:COLUMN\s+)?(\w+)",
+_ALTER_TABLE_RE = re.compile(
+    r"ALTER\s+TABLE\s+(\w+)\b",
     re.IGNORECASE,
 )
...
-for m in _ADD_COLUMN_RE.finditer(sql):
+for m in _ALTER_TABLE_RE.finditer(sql):

Also applies to: 135-146

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_retrofit_migrations.py` around lines 49 - 53, Update the
retrofit detection logic around _ADD_COLUMN_RE and its usages to recognize every
ALTER TABLE statement, including column renames, column drops, and table
renames, rather than only ADD operations. Preserve the existing migration
handling while ensuring all supported ALTER TABLE forms are included when
checking baseline-at-latest databases.


# CREATE [UNIQUE] INDEX [IF NOT EXISTS] <name> ON <table> (col, col, ...)
_CREATE_INDEX_RE = re.compile(
r"CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?\w+\s+ON\s+(\w+)",
re.IGNORECASE,
)

# CREATE TABLE [IF NOT EXISTS] <table> ( ... )
_CREATE_TABLE_RE = re.compile(
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(",
re.IGNORECASE,
)


@dataclass
class Violation:
path: Path
store: str
version: int
table: str
kind: str # "ALTER TABLE ADD COLUMN" or "CREATE INDEX"
detail: str

def __str__(self) -> str:
fix = (
"move this migration into a guarded _post_init coroutine "
"(PRAGMA table_info check + ALTER TABLE only when absent)"
)
return (
f"{self.path}: store '{self.store}', migration v{self.version}, "
f"table '{self.table}' -- {self.kind} on a SCHEMA table\n"
f" detail: {self.detail}\n"
f" fix: {fix}"
)
Comment on lines +77 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

2. Index fix text is wrong 🐞 Bug ⚙ Maintainability

Violation.__str__ always recommends a PRAGMA table_info + ALTER TABLE guarded _post_init, which
is inapplicable to CREATE INDEX violations and can misdirect remediation.
Agent Prompt
### Issue description
The violation message hardcodes a remediation meant for ADD COLUMN retrofits (PRAGMA table_info + ALTER TABLE) even when the detected violation is `CREATE INDEX`. This can cause developers to apply the wrong fix.

### Issue Context
`_check_migration_sql()` emits violations with `kind="CREATE INDEX"`, but `Violation.__str__()` does not branch on `self.kind`.

### Fix Focus Areas
- scripts/check_retrofit_migrations.py[68-87]
- scripts/check_retrofit_migrations.py[148-160]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



def _resolve_string(node: ast.AST, string_constants: dict[str, str]) -> str | None:
"""Resolve an AST node to a string value, following constant references."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name) and node.id in string_constants:
return string_constants[node.id]
return None


def _resolve_list_node(node: ast.AST, list_constants: dict[str, ast.List]) -> ast.List | None:
"""Resolve an AST node to a list node, following constant references."""
if isinstance(node, ast.List):
return node
if isinstance(node, ast.Name) and node.id in list_constants:
return list_constants[node.id]
return None


def _normalize(sql: str) -> str:
"""Normalize SQL for comparison: collapse whitespace, lowercase."""
return re.sub(r"\s+", " ", sql).strip().lower()


def _extract_schema_tables(schema_sql: str) -> set[str]:
"""Return the set of table names declared in CREATE TABLE statements."""
return {m.group(1) for m in _CREATE_TABLE_RE.finditer(schema_sql)}
Comment on lines +113 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Normalize and support legal SQLite table identifiers.

SQLite table names are case-insensitive, but captured names are compared case-sensitively: CREATE TABLE Users plus ALTER TABLE users ... bypasses the guard. Quoted identifiers such as "users" are legal too but do not match these regexes. Normalize identifiers before comparison and recognize quoted forms.

Also applies to: 136-151

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_retrofit_migrations.py` around lines 113 - 115, Update
_extract_schema_tables and the related table-name extraction used by the
migration checks to recognize legal quoted SQLite identifiers and normalize
captured names consistently. Strip supported identifier quotes and compare
normalized names case-insensitively so CREATE TABLE Users matches ALTER TABLE
users and quoted forms. Apply the same normalization to both schema declarations
and migration statements.



def _check_migration_sql(
sql: str,
schema_tables: set[str],
schema_sql_normalized: str,
path: Path,
store: str,
version: int,
) -> list[Violation]:
"""Check a single migration SQL string for retrofit patterns."""
violations: list[Violation] = []

# Bootstrap: if the migration SQL is the SCHEMA itself (initial creation),
# skip it -- CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS are
# idempotent and this is not a retrofit.
if _normalize(sql) == schema_sql_normalized:
return violations

# ALTER TABLE <schema_table> ADD COLUMN <col> -> retrofit
for m in _ADD_COLUMN_RE.finditer(sql):
table = m.group(1)
if table in schema_tables:
violations.append(Violation(
path=path,
store=store,
version=version,
table=table,
kind="ALTER TABLE ADD COLUMN",
detail=m.group(0).strip(),
))

# CREATE INDEX ... ON <schema_table> -> retrofit (baseline skips it)
for m in _CREATE_INDEX_RE.finditer(sql):
table = m.group(1)
if table in schema_tables:
violations.append(Violation(
path=path,
store=store,
version=version,
table=table,
kind="CREATE INDEX",
detail=m.group(0).strip(),
))

return violations


def find_violations(path: Path) -> list[Violation]:
"""Run the static check against a single Python file. Returns violations."""
try:
source = path.read_text(encoding="utf-8", errors="ignore")
tree = ast.parse(source)
except (OSError, SyntaxError):
return []
Comment on lines +166 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail closed when a source file cannot be analyzed.

Line 169 converts read/parse failures into “no violations,” so main() can print clean while skipping a store entirely. Raise/report an analysis error and exit nonzero; also avoid errors="ignore", which can silently alter valid non-UTF-8 Python source before parsing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_retrofit_migrations.py` around lines 166 - 170, Update the
source-loading and parsing logic in the relevant migration-analysis function to
stop treating OSError, UnicodeDecodeError, and SyntaxError as an empty violation
list: remove errors="ignore", report or propagate an analysis error, and ensure
main() exits nonzero instead of printing clean when any store cannot be
analyzed.


# Collect all module-level string constant assignments (name -> value).
string_constants: dict[str, str] = {}
list_constants: dict[str, ast.List] = {}
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
string_constants[target.id] = node.value.value
elif isinstance(node.value, ast.List):
list_constants[target.id] = node.value

Comment on lines +175 to +183

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Annassign skipped in ast 🐞 Bug ≡ Correctness

find_violations() only inspects ast.Assign, so stores declaring MIGRATIONS: list = [...] (an
ast.AnnAssign) won’t be analyzed and retrofit migrations can bypass this guard silently.
Agent Prompt
### Issue description
`scripts/check_retrofit_migrations.py` only processes `ast.Assign` nodes when collecting constants and when locating `SCHEMA` / `MIGRATIONS`. In this repo, several stores declare `MIGRATIONS` using annotated assignment syntax (`MIGRATIONS: list = [...]`), which parses as `ast.AnnAssign` and is therefore skipped. This creates false negatives where the guard reports clean even when violations exist.

### Issue Context
You need to treat both `ast.Assign` and `ast.AnnAssign` consistently in:
- collecting `string_constants` and `list_constants`
- detecting module-level `SCHEMA` / `MIGRATIONS`
- detecting class-level `SCHEMA` / `MIGRATIONS`

### Fix Focus Areas
- scripts/check_retrofit_migrations.py[172-206]
- scripts/check_retrofit_migrations.py[215-237]
- scripts/check_retrofit_migrations.py[241-272]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

# Also collect string constants defined inside class bodies (for
# SCHEMA = "..." inside a class).
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
if target.id not in string_constants:
string_constants[target.id] = node.value.value
Comment on lines +172 to +191

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Resolve constants within each class scope.

Class-level list constants are never added to list_constants, so MIGRATIONS = _MIGRATIONS is skipped. The shared string_constants map also lets identically named class-local SQL constants resolve to the first class encountered. Build separate symbol maps from each ClassDef.body before resolving that class’s SCHEMA and MIGRATIONS.

Also applies to: 220-235

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_retrofit_migrations.py` around lines 172 - 191, Update the
constant-resolution logic for each ClassDef to build class-local
string_constants and list_constants maps from that class body, including direct
string and list assignments and their aliases. Use these maps when resolving
that class’s SCHEMA and MIGRATIONS, while retaining module-level constants
separately so identically named class-local SQL constants do not leak across
classes.


violations: list[Violation] = []

# --- Module-level stores (e.g. scheduling/worker_heartbeat.py) ---
module_schema: str | None = None
module_migrations: ast.List | None = None
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
if target.id == "SCHEMA":
module_schema = _resolve_string(node.value, string_constants)
elif target.id == "MIGRATIONS":
module_migrations = _resolve_list_node(node.value, list_constants)

if module_schema and module_migrations:
schema_tables = _extract_schema_tables(module_schema)
schema_norm = _normalize(module_schema)
store_name = path.stem
violations.extend(
_check_migrations_list(module_migrations, string_constants, schema_tables, schema_norm, path, store_name)
)

# --- Class-level stores (e.g. contacts_store.py, invite_store.py) ---
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue

class_schema: str | None = None
class_migrations: ast.List | None = None
for item in node.body:
if isinstance(item, ast.Assign):
for target in item.targets:
if isinstance(target, ast.Name):
if target.id == "SCHEMA":
class_schema = _resolve_string(item.value, string_constants)
elif target.id == "MIGRATIONS":
class_migrations = _resolve_list_node(item.value, list_constants)

if class_schema and class_migrations:
schema_tables = _extract_schema_tables(class_schema)
schema_norm = _normalize(class_schema)
violations.extend(
_check_migrations_list(class_migrations, string_constants, schema_tables, schema_norm, path, node.name)
)

return violations


def _check_migrations_list(
migrations_node: ast.List,
string_constants: dict[str, str],
schema_tables: set[str],
schema_norm: str,
path: Path,
store_name: str,
) -> list[Violation]:
"""Check each (version, sql) entry in a MIGRATIONS list AST node."""
violations: list[Violation] = []
for elt in migrations_node.elts:
if not isinstance(elt, ast.Tuple) or len(elt.elts) < 2:
continue
version_node = elt.elts[0]
sql_node = elt.elts[1]

# Resolve version (must be a constant int).
if not isinstance(version_node, ast.Constant) or not isinstance(version_node.value, int):
continue
version = version_node.value

# Resolve SQL: string literal or constant reference.
sql = _resolve_string(sql_node, string_constants)
if sql is None:
# Callable (function) or unresolvable -- cannot statically analyze.
continue

violations.extend(
_check_migration_sql(sql, schema_tables, schema_norm, path, store_name, version)
)

return violations


def find_all_violations(root: Path = STORES_ROOT) -> list[Violation]:
"""Walk every Python file under the stores root and collect violations."""
violations: list[Violation] = []
if not root.is_dir():
return violations
for py_file in sorted(root.rglob("*.py")):
violations.extend(find_violations(py_file))
return violations


def main(argv: list[str] | None = None) -> int:
violations = find_all_violations()
if not violations:
print("retrofit-migration-guard: clean")
return 0
for v in violations:
print(f"RETROFIT MIGRATION VIOLATION: {v}")
return 1
Comment on lines +164 to +292

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add the tests requested for this guard.

This adds the release-gating analyzer without test changes. Cover clean/violation CLI statuses, module and class stores, named constants, bootstrap MIGRATIONS = [(1, SCHEMA)], ALTER/INDEX detection, and unparseable inputs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_retrofit_migrations.py` around lines 164 - 292, Add tests for
the retrofit migration guard covering clean and violation CLI exit statuses,
module-level and class-level stores, named string/list constants, bootstrap
MIGRATIONS entries using SCHEMA, ALTER and INDEX detection, and unreadable or
syntactically invalid inputs. Exercise the existing find_violations,
find_all_violations, and main symbols without changing analyzer behavior.

Comment on lines +285 to +292

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Guard not enforced in ci 🐞 Bug ☼ Reliability

The new guard script isn’t invoked by the existing GitHub Actions gates (which already run
check_schema_migrations.py), so it won’t actually block retrofit-migration mistakes on PRs.
Agent Prompt
### Issue description
The PR adds `scripts/check_retrofit_migrations.py` but does not wire it into CI (e.g., the doc-gate workflow that already runs the related schema-migration guard). As a result, the intended protection is not automatically enforced.

### Issue Context
There is an existing precedent:
- `scripts/check_schema_migrations.py` is run in `.github/workflows/doc-gate.yml`
- `tests/test_check_schema_migrations.py` validates the guard

### Fix Focus Areas
- .github/workflows/doc-gate.yml[31-36]
- scripts/check_retrofit_migrations.py[285-296]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



if __name__ == "__main__":
sys.exit(main())
Loading