Skip to content
Closed
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions .github/workflows/doc-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
- name: Retrofit-migration guard (#2188)
run: python scripts/check_retrofit_migrations.py

- name: Schema-column guard (tsk-hrzgip)
run: python scripts/check_schema_column_migrations.py

- name: Diff gate (Layer B)
env:
BASE_REF: ${{ github.base_ref }}
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/tsk-hrzgip-schema-column-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Added
- New `schema-column-guard` static check (`scripts/check_schema_column_migrations.py`) plus matching doc-gate step; flags any column added to a `CREATE TABLE` inside a store's `SCHEMA` with no matching `ALTER TABLE ... ADD COLUMN` in the same file, including the previously-uncovered case of zero migration at all (proven on PR #2416).
10 changes: 10 additions & 0 deletions docs/contributor-pitfalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ upgraded DBs lose the feature at runtime. Use the guarded `_post_init` pattern
builds the PRE-change schema first. Fresh-DB tests are structurally blind to
this class (#2043: `peer_fingerprint`).

**19. Adding a column straight into SCHEMA bricks every existing install.**
`CREATE TABLE IF NOT EXISTS` is a no-op on existing tables, so a new column
slotted into the `CREATE TABLE` body is silently absent on upgrade; the first
INSERT that touches it crashes with `table <t> has no column named <c>`. The
two existing migration guards cannot see this because neither has a migration
entry to inspect (both clean by vacuity), and CI cannot see it because tests
build fresh databases. The fix is the same guarded `_post_init` pattern as
pitfall 18; the check is `scripts/check_schema_column_migrations.py` (added
tsk-hrzgip after PR #2416 proved both old guards passed on this exact brick).

## Process

**13. A fix and its test belong in one PR.**
Expand Down
249 changes: 249 additions & 0 deletions scripts/check_schema_column_migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Static guard for SCHEMA-only column adds with no migration (tsk-hrzgip).

``BaseStore.init()`` runs a store's ``SCHEMA`` string (CREATE TABLE + CREATE
INDEX) at boot. ``CREATE TABLE IF NOT EXISTS`` is a no-op on existing
databases, so a new column added straight into the ``CREATE TABLE`` body is
silently absent on upgrade. The first INSERT or SELECT that touches it then
crashes with ``table <t> has no column named <c>`` on every existing install.
This is the brick that the two existing migration guards cannot see, because
neither has a migration entry to inspect (taOS PR #2416 proved both clean on
exactly this case).

The mandated pattern is a guarded ``_post_init`` coroutine: ``PRAGMA
table_info`` check + ``ALTER TABLE <t> ADD COLUMN <c>`` only when absent.

This script statically inspects every Python file under ``tinyagentos/`` and
flags, for every CREATE TABLE in a SCHEMA string: a column that

(a) is declared in that CREATE TABLE in the CURRENT file, AND
(b) has NO matching ``ALTER TABLE <t> ADD [COLUMN] <c>`` anywhere in the
SAME file (no _post_init migration runs it), AND
(c) was NOT in the CREATE TABLE column list on ``origin/dev`` (it is newly
added by this change -- so existing columns that have always lived in
SCHEMA do not trip the guard).

The third condition is what keeps it from flagging every column in the
repo: comparison is done against ``git show origin/dev:<path>`` for the same
file, only NEW columns relative to that snapshot count.

CI does NOT catch it because tests build fresh databases (which always have
the column); this guard exists to fill that gap.

Usage:
python scripts/check_schema_column_migrations.py
Invoked by the ``schema-column-guard`` step in
``.github/workflows/doc-gate.yml``.

Prints ``schema-column-guard: clean`` and exits 0 when no violations, or
prints each violation and exits 1.

Dependency-light: stdlib only (ast + re + pathlib + subprocess).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Stale dependency list in docstring.

The docstring claims the script uses ast + re + pathlib + subprocess, but ast is never imported or used. This is misleading for readers comparing it with the sibling check_retrofit_migrations.py (which actually uses ast). Either drop ast from the dependency-light line or explain why it is mentioned.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"""
from __future__ import annotations

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

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

# Tables that are not "user data" stores and therefore not subject to this
# guard. The migration runner's bookkeeping table is created on first boot
# before any store init runs, so columns added to it on origin/dev are
# genuinely new for the very DBs that need them and would generate noise.
_EXCLUDED_TABLES = frozenset({"schema_migrations"})

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

# ALTER TABLE <table> ADD [COLUMN] <col> ...
_ADD_COLUMN_RE = re.compile(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: _ADD_COLUMN_RE does not anchor on SQL boundaries, so it will also match strings in unrelated contexts.

The same concern as the critical finding above, but worth a separate call-out: the regex matches ALTER\s+TABLE\s+(\w+)\s+ADD\s+(?:COLUMN\s+)?(\w+) literally anywhere in the file. There is no attempt to restrict to multi-line SQL string literals or to the body of a _post_init function. Even Python identifiers like ALTER_TABLE_ADD_COLUMN_HELPER = ... (which would not match because of underscores, but AlterTable foo add bar in a name would also be filtered out only by the lack of whitespace) are not a risk — but the broader false-negative window on docstrings/comments is real and should be closed.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

r"ALTER\s+TABLE\s+(\w+)\s+ADD\s+(?:COLUMN\s+)?(\w+)",
re.IGNORECASE,
)


@dataclass
class Violation:
path: Path
table: str
column: str
detail: str

def __str__(self) -> str:
fix = (
"add a guarded _post_init coroutine that ALTERs this column "
"into place after a PRAGMA table_info check"
)
return (
f"{self.path}: table '{self.table}', column '{self.column}' "
f"added to SCHEMA with no migration\n"
f" detail: {self.detail}\n"
f" fix: {fix}"
)


def _split_columns(body: str) -> set[str]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: _split_columns uses a hand-rolled paren-depth scanner; consider documenting the supported dialect edge cases or accepting the limitation.

The scanner splits on top-level commas and skips segments whose first token looks like a table-level constraint. Edge cases not handled: a column whose DEFAULT expression itself contains a top-level comma at paren-depth 0 (unusual but legal in some extensions), and a column whose name collides with one of the NON_COLUMN_KEYWORDS (e.g. a quoted column literally named select). Neither is fatal but they would silently drop or misclassify a column. A one-line comment in the function noting the limitation, or an explicit raise NotImplementedError for the rare case, would help future readers.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"""Extract declared column names from a CREATE TABLE body.

Splits on commas that are not inside parentheses (so function/default
expressions and inline CHECK(...) are handled), then keeps the first
whitespace-delimited token of each segment as the column name (unless
that segment is an inline table-level constraint, which does not add
a column).
"""
_INLINE_CONSTRAINT_RE = re.compile(
r"^\s*(?:CONSTRAINT\s+\w+\s+)?(?:PRIMARY\s+KEY|UNIQUE|CHECK|FOREIGN\s+KEY|REFERENCES)\b",
re.IGNORECASE,
)
_COLUMN_NAME_RE = re.compile(r"^\s*(\w+)\s+", re.IGNORECASE)
_NON_COLUMN_KEYWORDS = {
"create", "table", "primary", "key", "unique", "check", "foreign",
"references", "constraint", "default", "not", "null", "integer",
"text", "real", "blob", "numeric", "autoincrement", "if", "exists",
"select", "on", "and", "or", "as", "collate", "generated", "always",
}

columns: set[str] = set()
depth = 0
segments: list[str] = []
current: list[str] = []
for ch in body:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if ch == "," and depth == 0:
segments.append("".join(current))
current = []
else:
current.append(ch)
segments.append("".join(current))

for seg in segments:
if _INLINE_CONSTRAINT_RE.match(seg):
continue
m = _COLUMN_NAME_RE.match(seg)
if not m:
continue
name = m.group(1)
if name.lower() in _NON_COLUMN_KEYWORDS:
continue
columns.add(name)
return columns


def _origin_dev_columns(path: Path) -> dict[str, set[str]] | None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Silent fallback to "treat file as new" when git show origin/dev:<path> fails masks CI misconfiguration.

_origin_dev_columns returns None on any failure (missing ref, transient network error, ref name change), and the caller then treats every column as new — i.e. every existing column on every store becomes a "SCHEMA-COLUMN VIOLATION". CI that loses its origin/dev ref (renamed branch, shallow clone, fork PR with restricted refs) would suddenly fail with thousands of violations rather than a single actionable error. Emit a loud warning and exit non-zero when the ref is missing, and only fall back to "new file" when the file genuinely does not exist on origin/dev.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"""Return {table: columns} for CREATE TABLE bodies in the file on
``origin/dev``. Returns None if the file is missing on origin/dev (new
file -- treat every column as new so violations surface naturally)."""
rel = path.relative_to(REPO_ROOT)
try:
result = subprocess.run(
["git", "show", f"origin/dev:{rel.as_posix()}"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
out: dict[str, set[str]] = {}
for tm in _CREATE_TABLE_RE.finditer(result.stdout):
out[tm.group(1)] = _split_columns(tm.group(2))
return out


def _extract_schemas(source: str) -> list[str]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: _extract_schemas matches any triple-quoted string containing CREATE TABLE, not specifically SCHEMA = """...""" constants.

A module/class docstring, a long comment, or an unrelated raw SQL string that happens to mention CREATE TABLE (e.g. example snippets in docstrings) will be picked up and its columns compared against origin/dev. This will produce false positives and silently fail CI on innocent docs. The sibling check_retrofit_migrations.py avoids this by using ast to resolve only SCHEMA/MIGRATIONS constant assignments — that proven pattern would be safer here.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"""Find SCHEMA string constants in a Python source.

Only triple-quoted strings are scanned: the existing migration guards
already require multi-line SQL blocks (CREATE TABLE + CREATE INDEX),
so single-line string literals in source code (which would otherwise
double-count a block already captured by the triple-quoted regex) are
ignored. Files that don't define a SCHEMA contribute nothing.
"""
out: list[str] = []
seen: set[int] = set()
for m in re.finditer(r'"""(.*?)"""', source, re.DOTALL):
if "CREATE TABLE" in m.group(1):
out.append(m.group(1))
seen.add(m.start())
for m in re.finditer(r"'''(.*?)'''", source, re.DOTALL):
if "CREATE TABLE" in m.group(1) and m.start() not in seen:
out.append(m.group(1))
return out


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")
except OSError:
return []

schemas = _extract_schemas(source)
if not schemas:
return []

origin_cols = _origin_dev_columns(path)

added_columns: set[tuple[str, str]] = set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: _ADD_COLUMN_RE is matched against the entire file source, including docstrings and comments, which causes false negatives.

Any ALTER TABLE <t> ADD [COLUMN] <c> mentioned in a Python docstring, # comment, log message, test fixture, or unrelated string literal anywhere in the same file will mark (t, c) as "having a migration" and silence the real violation. A contributor who writes a comment like # See ALTER TABLE auth_requests ADD COLUMN kind in PR #2416 would bypass the guard. Constrain the search to SQL inside the actual _post_init body (e.g. parse the _post_init AST node, or strip comments/strings before regexing), or at minimum ignore text inside triple-quoted and #-prefixed lines.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

for m in _ADD_COLUMN_RE.finditer(source):
added_columns.add((m.group(1), m.group(2)))

violations: list[Violation] = []
for schema in schemas:
for tm in _CREATE_TABLE_RE.finditer(schema):
table = tm.group(1)
if table in _EXCLUDED_TABLES:
continue
current_cols = _split_columns(tm.group(2))
baseline_cols = (
origin_cols.get(table, set()) if origin_cols is not None else set()
)
new_cols = current_cols - baseline_cols
for col in sorted(new_cols):
if (table, col) not in added_columns:
violations.append(Violation(
path=path,
table=table,
column=col,
detail=f"new column '{col}' in CREATE TABLE {table} with no ALTER TABLE {table} ADD COLUMN {col} in this file",
))
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:
args = argv if argv is not None else sys.argv[1:]
root = Path(args[0]) if args else STORES_ROOT
violations = find_all_violations(root)
if not violations:
print("schema-column-guard: clean")
return 0
for v in violations:
print(f"SCHEMA-COLUMN VIOLATION: {v}")
return 1


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Missing trailing newline at end of file.

The diff shows \ No newline at end of file. POSIX text files should end with a newline; many tools (cat, wc -l, some diff/merge drivers, several editors) misbehave on files without one. Add a final \n. Same applies to tests/scripts/test_check_schema_column_migrations.py (line 225) and changelog.d/tsk-hrzgip-schema-column-guard.md.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Loading
Loading