pythia-plsql 0.3.4__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {pythia_plsql-0.3.4/scripts/pythia_plsql.egg-info → pythia_plsql-0.4.0}/PKG-INFO +8 -2
  2. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/README.md +7 -1
  3. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/pyproject.toml +1 -1
  4. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia.py +183 -7
  5. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0/scripts/pythia_plsql.egg-info}/PKG-INFO +8 -2
  6. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-explore/SKILL.md +1 -1
  7. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_phase3.py +75 -0
  8. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_phase5.py +27 -0
  9. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/LICENSE +0 -0
  10. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/compile-errors.sql +0 -0
  11. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/dependencies.sql +0 -0
  12. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/impact.sql +0 -0
  13. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/invalid-objects.sql +0 -0
  14. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/name-occupants.sql +0 -0
  15. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/object-source.sql +0 -0
  16. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/plscope-enabled.sql +0 -0
  17. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/plscope-statements.sql +0 -0
  18. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/plscope-usages.sql +0 -0
  19. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/session-privileges.sql +0 -0
  20. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/similar-candidates.sql +0 -0
  21. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/source.sql +0 -0
  22. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
  23. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  24. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  25. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  26. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  27. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/setup.cfg +0 -0
  28. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-apply/SKILL.md +0 -0
  29. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  30. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-impact/SKILL.md +0 -0
  31. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-review/SKILL.md +0 -0
  32. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-review/reference/antipatterns.md +0 -0
  33. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-setup/SKILL.md +0 -0
  34. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-skill-author/SKILL.md +0 -0
  35. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-write/SKILL.md +0 -0
  36. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-write/reference/patterns.md +0 -0
  37. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_install.py +0 -0
  38. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_phase1.py +0 -0
  39. {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_phase2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -115,13 +115,19 @@ and WSL are all CI-tested.
115
115
  | `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
116
116
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
117
117
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
118
- | `cols` columns + types | `similar` programs named like this | |
118
+ | `cols` columns + types | `similar` programs named like this | `history` every captured version |
119
119
  | `grep` search all source | | |
120
120
  | `sql` free query (SELECT/WITH only) | | |
121
121
 
122
122
  Every command takes `--json` (machine output), `--conn` (pick a connection), and
123
123
  caps output with explicit truncation markers so context windows stay intact.
124
124
 
125
+ **The safety net covers hand edits too**: `src` and `impact` snapshot the
126
+ object silently into the journal, each with a runnable rollback file, so a
127
+ change made later in SQL Developer still has something to go back to —
128
+ `pythia history <OBJECT>` lists the versions. Source that moved with no
129
+ apply behind it is reported as drift.
130
+
125
131
  **Your house style is config, not folklore**: put naming patterns in
126
132
  `.pythia/conventions.json` and apply previews warn when a new object's name
127
133
  drifts; put the prose rules in `.pythia/conventions.md` and the skills make
@@ -101,13 +101,19 @@ and WSL are all CI-tested.
101
101
  | `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
102
102
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
103
103
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
104
- | `cols` columns + types | `similar` programs named like this | |
104
+ | `cols` columns + types | `similar` programs named like this | `history` every captured version |
105
105
  | `grep` search all source | | |
106
106
  | `sql` free query (SELECT/WITH only) | | |
107
107
 
108
108
  Every command takes `--json` (machine output), `--conn` (pick a connection), and
109
109
  caps output with explicit truncation markers so context windows stay intact.
110
110
 
111
+ **The safety net covers hand edits too**: `src` and `impact` snapshot the
112
+ object silently into the journal, each with a runnable rollback file, so a
113
+ change made later in SQL Developer still has something to go back to —
114
+ `pythia history <OBJECT>` lists the versions. Source that moved with no
115
+ apply behind it is reported as drift.
116
+
111
117
  **Your house style is config, not folklore**: put naming patterns in
112
118
  `.pythia/conventions.json` and apply previews warn when a new object's name
113
119
  drifts; put the prose rules in `.pythia/conventions.md` and the skills make
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.3.4"
7
+ version = "0.4.0"
8
8
  description = "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -42,6 +42,7 @@ you always know whether you saw everything.
42
42
  pythia journal restore <id>
43
43
  pythia policy
44
44
  pythia install
45
+ pythia history MY_PACKAGE
45
46
  pythia unistr "Nhóm không được để trống"
46
47
  pythia agent-user --save
47
48
  """
@@ -631,6 +632,84 @@ def load_settings(root):
631
632
  sys.exit(f"Cannot parse {path}: {e}")
632
633
 
633
634
 
635
+ def source_sha(text):
636
+ import hashlib
637
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
638
+
639
+
640
+ def fetch_source(conn, schema, name):
641
+ """Current source of an object, as ALL_SOURCE holds it. ('', None) when
642
+ the name is not a source object (a table, say)."""
643
+ _, rows = run_query(conn, load_query("source.sql"), {"s": schema, "n": name})
644
+ if not rows:
645
+ return "", None
646
+ return "".join(cell(r[2]) for r in rows), rows[0][0]
647
+
648
+
649
+ def last_known_state(root, name):
650
+ """What the database held, as far as the journal knows: the newest entry
651
+ for this object, and the source that entry says was really there.
652
+ A preview wrote nothing, so its `before` is the real state."""
653
+ for eid in list_journal_entries(root): # newest first
654
+ e = read_journal_entry(root, eid)
655
+ m = e["meta"]
656
+ if str(m.get("object", "")).upper() != name.upper():
657
+ continue
658
+ real = e["after"] if (m.get("applied") or m.get("snapshot")) else e["before"]
659
+ return real, eid, m
660
+ return None, None, None
661
+
662
+
663
+ def auto_snapshot(conn, schema, name, ns):
664
+ """Capture an object's source the moment an agent focuses on it, so a
665
+ later hand-edit outside pythia still has something to go back to.
666
+
667
+ Silent by design: writing to the journal costs the agent no context, and
668
+ a line of "snapshot taken" on every read would cost it on every read.
669
+ The one thing worth saying is drift — the source moved without an apply
670
+ of ours explaining it — and that goes to stderr, so --json stays clean.
671
+
672
+ Returns a drift warning, or None.
673
+ """
674
+ root = getattr(ns, "project_root", None)
675
+ if root is None or not load_settings(root).get("auto_snapshot", True):
676
+ return None
677
+ try:
678
+ text, otype = fetch_source(conn, schema, name)
679
+ if not text.strip():
680
+ return None # not a source object; nothing to keep
681
+ known, eid, meta = last_known_state(root, name)
682
+ if known is not None and source_sha(known) == source_sha(text):
683
+ return None # unchanged — no entry, no output
684
+ write_journal_entry(root, otype or "OBJECT", name.upper(), text, text,
685
+ {"schema": schema, "connection": ns.conn_name,
686
+ "snapshot": True, "applied": False,
687
+ "sha": source_sha(text)})
688
+ if known is None:
689
+ return None # first sighting is a baseline
690
+ rollback = journal_root(root) / eid / "restore.sql"
691
+ return (f"! {name.upper()} changed outside pythia since {eid}.\n"
692
+ f" Rollback file for the previous version: {rollback}\n"
693
+ f" All versions: {invocation()} history {name.upper()}")
694
+ except Exception: # noqa: BLE001
695
+ return None # a safety net must never break a read
696
+
697
+
698
+ def report_drift(msg):
699
+ if msg:
700
+ print(paint(msg, "yellow", color_enabled(sys.stderr)), file=sys.stderr)
701
+
702
+
703
+ def journaled_objects(root):
704
+ names = {}
705
+ for eid in list_journal_entries(root): # newest first
706
+ m = read_journal_entry(root, eid)["meta"]
707
+ n = str(m.get("object", "")).upper()
708
+ if n and n != "STATEMENT" and n not in names:
709
+ names[n] = eid[:19] # the entry's timestamp
710
+ return names
711
+
712
+
634
713
  def undo_group_action(ns):
635
714
  """Undoing a CREATE is a DROP, which is `structural` — so the policy on
636
715
  that group decides whether the restore command we print can run."""
@@ -827,6 +906,43 @@ def cmd_check(conn, schema, ns):
827
906
  if warn:
828
907
  print("\n" + paint(warn, "yellow", color_enabled(sys.stderr)),
829
908
  file=sys.stderr)
909
+ report_drift(drift_summary(conn, schema, ns))
910
+
911
+
912
+ def drift_summary(conn, schema, ns):
913
+ """One line, only when objects pythia knows have moved since it last saw
914
+ them. Uses LAST_DDL_TIME — a single query for every journaled object,
915
+ instead of re-reading every source.
916
+ ponytail: LAST_DDL_TIME also ticks on a bare recompile, so this is a
917
+ 'go look' signal; `history` and the src/impact hash comparison are the
918
+ precise ones."""
919
+ root = getattr(ns, "project_root", None)
920
+ if root is None:
921
+ return None
922
+ try:
923
+ known = journaled_objects(root)
924
+ if not known:
925
+ return None
926
+ names = sorted(known)[:200] # one query, bounded
927
+ binds = {"s": schema}
928
+ placeholders = []
929
+ for i, n in enumerate(names):
930
+ binds[f"n{i}"] = n
931
+ placeholders.append(f":n{i}")
932
+ _, rows = run_query(
933
+ conn, "select object_name, to_char(last_ddl_time,"
934
+ "'yyyy-mm-dd\"T\"hh24-mi-ss') from all_objects "
935
+ f"where owner = :s and object_name in ({','.join(placeholders)})",
936
+ binds)
937
+ moved = [r[0] for r in rows if str(r[1]) > known.get(str(r[0]), "")]
938
+ if not moved:
939
+ return None
940
+ shown = ", ".join(moved[:3]) + ("…" if len(moved) > 3 else "")
941
+ return (f"! {len(moved)} object(s) changed since pythia last saw them: "
942
+ f"{shown}\n Versions and restores: {invocation()} history "
943
+ f"<NAME>")
944
+ except Exception: # noqa: BLE001
945
+ return None
830
946
 
831
947
 
832
948
  def cmd_ls(conn, schema, ns):
@@ -846,8 +962,10 @@ def cmd_src(conn, schema, ns):
846
962
  if not rows:
847
963
  sys.exit(f"No source found for {ns.name!r} in schema {schema}.")
848
964
  total = len(rows)
965
+ drift = auto_snapshot(conn, schema, ns.name, ns)
849
966
  shown, truncated = clip(rows, ns.max_lines, ns.offset)
850
967
  emit_source(ns, shown, truncated, total)
968
+ report_drift(drift)
851
969
 
852
970
 
853
971
  def cmd_args(conn, schema, ns):
@@ -955,19 +1073,26 @@ def cmd_deps(conn, schema, ns):
955
1073
  def cmd_impact(conn, schema, ns):
956
1074
  cols, rows = run_query(conn, load_query("impact.sql"),
957
1075
  {"s": schema, "n": ns.name, "depth": ns.depth})
1076
+ # impact is mandatory before any change, so it is the surest "about to
1077
+ # touch this" signal there is — the best possible place to capture a
1078
+ # before-state the developer never had to ask for
1079
+ drift = auto_snapshot(conn, schema, ns.name, ns)
958
1080
  shown, truncated = clip(rows, ns.limit)
959
1081
  if ns.json:
960
1082
  print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, shown, truncated,
961
1083
  summary=impact_summary(rows)))
1084
+ report_drift(drift)
962
1085
  return
963
1086
  if not rows:
964
1087
  print(f"-- nothing depends on {ns.name.upper()} "
965
1088
  f"(within {schema}, depth {ns.depth})")
1089
+ report_drift(drift)
966
1090
  return
967
1091
  sys.stdout.write(render_tree(shown, f"{schema}.{ns.name.upper()}"))
968
1092
  if truncated:
969
1093
  print(f"-- truncated at {len(shown)} rows (raise --limit, or --limit 0 for no cap)")
970
1094
  print(impact_summary(rows))
1095
+ report_drift(drift)
971
1096
 
972
1097
 
973
1098
  def cmd_similar(conn, schema, ns):
@@ -1351,6 +1476,48 @@ def cmd_policy(conn, schema, ns):
1351
1476
  print(ROLLBACK_TABLE)
1352
1477
 
1353
1478
 
1479
+ def cmd_history(conn, schema, ns):
1480
+ """Every version of one object the journal holds, newest first. Compact
1481
+ on purpose — an agent reads this to choose an id, not to read source."""
1482
+ root = ns.project_root
1483
+ name = ns.name.upper()
1484
+ rows = []
1485
+ for eid in list_journal_entries(root):
1486
+ e = read_journal_entry(root, eid)
1487
+ m = e["meta"]
1488
+ if str(m.get("object", "")).upper() != name:
1489
+ continue
1490
+ real = e["after"] if (m.get("applied") or m.get("snapshot")) else e["before"]
1491
+ kind = ("applied" if m.get("applied") else
1492
+ "snapshot" if m.get("snapshot") else "preview")
1493
+ rows.append({"entry": eid, "kind": kind,
1494
+ "lines": len(real.splitlines()),
1495
+ "sha": source_sha(real),
1496
+ "rollback_file": str(journal_root(root) / eid
1497
+ / "restore.sql")})
1498
+ if ns.json:
1499
+ print(json.dumps({"object": name, "versions": rows}))
1500
+ return
1501
+ if not rows:
1502
+ print(f"-- no journal history for {name}. It is captured the first "
1503
+ f"time you run `{invocation()} src {name}` or "
1504
+ f"`{invocation()} impact {name}`.")
1505
+ return
1506
+ prev = None
1507
+ for r in rows: # newest first; compare to the older one
1508
+ older = rows[rows.index(r) + 1] if rows.index(r) + 1 < len(rows) else None
1509
+ delta = ""
1510
+ if older and older["sha"] != r["sha"]:
1511
+ n = r["lines"] - older["lines"]
1512
+ delta = f" {n:+d} lines" if n else " content changed"
1513
+ print(f" {r['entry']:<44} {r['kind']:<9} {r['lines']:>5} lines{delta}")
1514
+ prev = r
1515
+ print(f"\n-- every version above has a ready-to-run rollback file:")
1516
+ print(f" {journal_root(root)}\\<entry>\\restore.sql")
1517
+ print(f"-- through pythia (previews first, you approve):")
1518
+ print(f" {invocation()} journal restore <entry>")
1519
+
1520
+
1354
1521
  def cmd_journal(conn, schema, ns):
1355
1522
  root = ns.project_root
1356
1523
  if ns.action == "list":
@@ -1370,14 +1537,17 @@ def cmd_journal(conn, schema, ns):
1370
1537
  import shutil
1371
1538
  removed = 0
1372
1539
  for eid in list_journal_entries(root):
1373
- if not read_journal_entry(root, eid)["meta"].get("applied"):
1374
- shutil.rmtree(journal_root(root) / eid)
1375
- removed += 1
1540
+ meta = read_journal_entry(root, eid)["meta"]
1541
+ if meta.get("applied") or meta.get("snapshot"):
1542
+ continue # applied = a real write; snapshot = the only
1543
+ # undo a hand-edit outside pythia will ever have
1544
+ shutil.rmtree(journal_root(root) / eid)
1545
+ removed += 1
1376
1546
  if ns.json:
1377
1547
  print(json.dumps({"pruned": removed}))
1378
1548
  else:
1379
- print(f"-- pruned {removed} preview-only entries; applied entries "
1380
- "(the real snapshots) are all kept")
1549
+ print(f"-- pruned {removed} preview-only entries; applied "
1550
+ "entries and snapshots are all kept")
1381
1551
  return
1382
1552
  if not ns.id:
1383
1553
  sys.exit(f"Usage: {invocation()} journal "
@@ -1752,9 +1922,11 @@ COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
1752
1922
  "impact": cmd_impact, "similar": cmd_similar, "plscope": cmd_plscope,
1753
1923
  "policy": cmd_policy, "journal": cmd_journal, "apply": cmd_apply,
1754
1924
  "conventions": cmd_conventions, "install": cmd_install,
1755
- "unistr": cmd_unistr, "agent-user": cmd_agent_user}
1925
+ "unistr": cmd_unistr, "agent-user": cmd_agent_user,
1926
+ "history": cmd_history}
1756
1927
 
1757
- NO_DB_COMMANDS = {"policy", "journal", "conventions", "install", "unistr"}
1928
+ NO_DB_COMMANDS = {"policy", "journal", "conventions", "install", "unistr",
1929
+ "history"}
1758
1930
 
1759
1931
 
1760
1932
  # --- CLI ---------------------------------------------------------------------
@@ -1860,6 +2032,10 @@ def build_parser():
1860
2032
  s.add_argument("--save", action="store_true",
1861
2033
  help="add the new credential as <conn>_agent and make it "
1862
2034
  "the default connection")
2035
+ s = sub.add_parser("history", parents=[common()],
2036
+ help="every captured version of one object, newest "
2037
+ "first — pick one to restore")
2038
+ s.add_argument("name")
1863
2039
  s = sub.add_parser("unistr", parents=[common()],
1864
2040
  help="Oracle unistr('...') literal for non-ASCII text "
1865
2041
  "(Vietnamese messages stay exact)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -115,13 +115,19 @@ and WSL are all CI-tested.
115
115
  | `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
116
116
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
117
117
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
118
- | `cols` columns + types | `similar` programs named like this | |
118
+ | `cols` columns + types | `similar` programs named like this | `history` every captured version |
119
119
  | `grep` search all source | | |
120
120
  | `sql` free query (SELECT/WITH only) | | |
121
121
 
122
122
  Every command takes `--json` (machine output), `--conn` (pick a connection), and
123
123
  caps output with explicit truncation markers so context windows stay intact.
124
124
 
125
+ **The safety net covers hand edits too**: `src` and `impact` snapshot the
126
+ object silently into the journal, each with a runnable rollback file, so a
127
+ change made later in SQL Developer still has something to go back to —
128
+ `pythia history <OBJECT>` lists the versions. Source that moved with no
129
+ apply behind it is reported as drift.
130
+
125
131
  **Your house style is config, not folklore**: put naming patterns in
126
132
  `.pythia/conventions.json` and apply previews warn when a new object's name
127
133
  drifts; put the prose rules in `.pythia/conventions.md` and the skills make
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pythia-explore
3
- description: Use when you need to understand anything in an Oracle schema - finding objects, reading PL/SQL source, signatures, table columns, DDL, searching code, or asking who uses what. ALSO use when a .sql file, dump, export, migration script or repo copy of an object appears to answer the question already, including when the developer points you at one: those files drift from the database and are wrong in ways that read as correct. The database is the only source of truth.
3
+ description: Use when you need to understand anything in an Oracle schema - finding objects, reading PL/SQL source, signatures, table columns, DDL, searching code, or asking who uses what. ALSO use when a .sql file, dump, export, migration script or repo copy of an object appears to answer the question already, including one the developer points you at - because those files drift from the database and are wrong in ways that read as correct. The database is the only source of truth.
4
4
  ---
5
5
 
6
6
  # Exploring the Schema
@@ -687,6 +687,81 @@ def test_report_admits_a_created_objects_undo_is_blocked():
687
687
  assert "will be refused" not in buf.getvalue().lower()
688
688
 
689
689
 
690
+ def test_auto_snapshot_captures_silently_and_writes_a_rollback_file():
691
+ """Every captured version leaves a runnable rollback file — that is the
692
+ whole point for work done by hand, outside pythia. And capturing costs
693
+ the agent no context: nothing is printed when nothing moved."""
694
+ import argparse
695
+ import contextlib
696
+ import io
697
+ src = "PROCEDURE P_X AS\nBEGIN\n NULL;\nEND;\n"
698
+ script = {"from all_source": ([("TYPE", "LINE", "TEXT")],
699
+ [("PROCEDURE", i + 1, ln + "\n")
700
+ for i, ln in enumerate(src.splitlines())])}
701
+ with tempfile.TemporaryDirectory() as td:
702
+ ns = argparse.Namespace(project_root=td, conn_name="DEV")
703
+ buf = io.StringIO()
704
+ with contextlib.redirect_stdout(buf):
705
+ drift = pythia.auto_snapshot(FakeConn(script), "APP", "P_X", ns)
706
+ assert drift is None # first sighting: a baseline
707
+ assert buf.getvalue() == "" # and not one token of output
708
+ ids = pythia.list_journal_entries(td)
709
+ assert len(ids) == 1
710
+ e = pythia.read_journal_entry(td, ids[0])
711
+ assert e["meta"]["snapshot"] is True
712
+ rollback = e["restore"]
713
+ assert rollback.startswith("CREATE OR REPLACE PROCEDURE P_X")
714
+ assert (pythia.journal_root(td) / ids[0] / "restore.sql").is_file()
715
+
716
+ # reading it again unchanged must not create a second entry
717
+ assert pythia.auto_snapshot(FakeConn(script), "APP", "P_X", ns) is None
718
+ assert pythia.list_journal_entries(td) == ids
719
+
720
+
721
+ def test_auto_snapshot_reports_drift_and_keeps_the_old_rollback():
722
+ """Source moved with no apply of ours behind it — say so once, and point
723
+ at the rollback file for the version that was there before."""
724
+ import argparse
725
+ old_src = "PROCEDURE P_X AS\nBEGIN\n NULL;\nEND;\n"
726
+ new_src = "PROCEDURE P_X AS\nBEGIN\n other_thing;\nEND;\n"
727
+
728
+ def script(text):
729
+ return {"from all_source": ([("TYPE", "LINE", "TEXT")],
730
+ [("PROCEDURE", i + 1, ln + "\n")
731
+ for i, ln in enumerate(text.splitlines())])}
732
+ with tempfile.TemporaryDirectory() as td:
733
+ ns = argparse.Namespace(project_root=td, conn_name="DEV")
734
+ pythia.auto_snapshot(FakeConn(script(old_src)), "APP", "P_X", ns)
735
+ first = pythia.list_journal_entries(td)[0]
736
+ drift = pythia.auto_snapshot(FakeConn(script(new_src)), "APP", "P_X", ns)
737
+ assert drift and "changed outside pythia" in drift
738
+ assert "restore.sql" in drift
739
+ # the older version's rollback is still on disk and still runnable
740
+ old_rollback = pythia.read_journal_entry(td, first)["restore"]
741
+ assert "NULL;" in old_rollback
742
+ assert len(pythia.list_journal_entries(td)) == 2
743
+
744
+
745
+ def test_prune_never_eats_a_snapshot():
746
+ import argparse
747
+ import contextlib
748
+ import io
749
+ import datetime
750
+ with tempfile.TemporaryDirectory() as td:
751
+ base = datetime.datetime(2026, 8, 27, 9, 0, 0)
752
+ pythia.write_journal_entry(td, "PROCEDURE", "P_A", "a", "b",
753
+ {"applied": False}, now=base)
754
+ snap = pythia.write_journal_entry(
755
+ td, "PROCEDURE", "P_B", "a", "a", {"snapshot": True,
756
+ "applied": False},
757
+ now=base.replace(minute=1))
758
+ ns = argparse.Namespace(action="prune", project_root=td, json=False,
759
+ id=None)
760
+ with contextlib.redirect_stdout(io.StringIO()):
761
+ pythia.cmd_journal(None, None, ns)
762
+ assert pythia.list_journal_entries(td) == [snap]
763
+
764
+
690
765
  def main():
691
766
  failed = 0
692
767
  for name, fn in sorted(globals().items()):
@@ -54,6 +54,33 @@ def test_frontmatter_is_valid_and_triggering():
54
54
  assert len(desc) <= 1024, f"{name}: description too long ({len(desc)})"
55
55
 
56
56
 
57
+ def test_frontmatter_is_parseable_yaml():
58
+ """A description holding an unquoted ": " is not valid YAML, and the
59
+ agent harness then falls back to the H1 heading — the skill keeps its
60
+ name but loses its trigger, so it silently stops firing. This shipped
61
+ once; it does not ship twice. Checked without PyYAML: the suites run on
62
+ the stdlib alone.
63
+ """
64
+ for name in sorted(EXPECTED):
65
+ path = SKILLS / name / "SKILL.md"
66
+ if not path.is_file():
67
+ continue
68
+ m = re.match(r"^---\n(.*?)\n---\n", path.read_text(encoding="utf-8"),
69
+ re.S)
70
+ assert m, f"{name}: no YAML frontmatter"
71
+ for line in m.group(1).splitlines():
72
+ km = re.match(r"^([A-Za-z_][\w.]*):\s+(.*)$", line)
73
+ if not km:
74
+ continue # continuation or nested line
75
+ value = km.group(2)
76
+ if value[:1] in "\"'":
77
+ continue # quoted: a colon inside is fine
78
+ assert ": " not in value, (
79
+ f"{name}: frontmatter '{km.group(1)}' holds an unquoted "
80
+ f"': ' — YAML reads that as a nested mapping and the field "
81
+ f"is lost. Rephrase or quote the value.")
82
+
83
+
57
84
  def test_skill_bodies_stay_within_budget():
58
85
  for name in sorted(EXPECTED):
59
86
  path = SKILLS / name / "SKILL.md"
File without changes
File without changes