pythia-plsql 0.11.2__tar.gz → 0.12.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 (45) hide show
  1. {pythia_plsql-0.11.2/scripts/pythia_plsql.egg-info → pythia_plsql-0.12.0}/PKG-INFO +1 -1
  2. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/pyproject.toml +1 -1
  3. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/name-occupants.sql +3 -2
  4. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/scripts/pythia.py +114 -3
  5. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0/scripts/pythia_plsql.egg-info}/PKG-INFO +1 -1
  6. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +1 -0
  7. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-apply/SKILL.md +12 -12
  8. pythia_plsql-0.12.0/skills/pythia-comment/SKILL.md +73 -0
  9. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-write/SKILL.md +2 -1
  10. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/using-pythia/SKILL.md +1 -0
  11. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/tests/test_phase3.py +128 -4
  12. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/tests/test_phase5.py +2 -2
  13. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/LICENSE +0 -0
  14. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/README.md +0 -0
  15. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/compile-errors.sql +0 -0
  16. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/current-schema.sql +0 -0
  17. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/dependencies.sql +0 -0
  18. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/impact.sql +0 -0
  19. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/invalid-objects.sql +0 -0
  20. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/object-names.sql +0 -0
  21. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/object-source.sql +0 -0
  22. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/plscope-enabled.sql +0 -0
  23. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/plscope-statements.sql +0 -0
  24. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/plscope-usages.sql +0 -0
  25. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/session-privileges.sql +0 -0
  26. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/similar-candidates.sql +0 -0
  27. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/queries/source.sql +0 -0
  28. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  29. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  30. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  31. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  32. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/setup.cfg +0 -0
  33. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-conventions/SKILL.md +0 -0
  34. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-explore/SKILL.md +0 -0
  35. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  36. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-impact/SKILL.md +0 -0
  37. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-review/SKILL.md +0 -0
  38. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-review/reference/antipatterns.md +0 -0
  39. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-setup/SKILL.md +0 -0
  40. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-skill-author/SKILL.md +0 -0
  41. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-spec/SKILL.md +0 -0
  42. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/skills/pythia-write/reference/patterns.md +0 -0
  43. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/tests/test_install.py +0 -0
  44. {pythia_plsql-0.11.2 → pythia_plsql-0.12.0}/tests/test_phase1.py +0 -0
  45. {pythia_plsql-0.11.2 → pythia_plsql-0.12.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.11.2
3
+ Version: 0.12.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.11.2"
7
+ version = "0.12.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" }
@@ -5,8 +5,9 @@
5
5
  -- ORA-00955. Found by an agent during the first field test.
6
6
  -- Binds: :s schema (object owner)
7
7
  -- :n object name
8
- -- Returns: OBJECT_TYPE
9
- select object_type
8
+ -- Returns: OBJECT_TYPE, EDITIONABLE ('Y'/'N', null when the type
9
+ -- has no edition — the apply reconciles the keyword to it)
10
+ select object_type, editionable
10
11
  from all_objects
11
12
  where owner = :s
12
13
  and object_name = upper(:n)
@@ -1240,6 +1240,46 @@ EDITIONABLE_KW_RE = re.compile(
1240
1240
  r"^create\s+(?:or\s+replace\s+)?((?:non?)?editionable)\b", re.I)
1241
1241
 
1242
1242
 
1243
+ CREATE_HEAD_RE = re.compile(
1244
+ r"^(create\s+(?:or\s+replace\s+)?)((?:non?)?editionable\s+)?", re.I)
1245
+
1246
+
1247
+ def reconcile_editionable(stmt, editionable):
1248
+ """Make the statement agree with the property the database already holds.
1249
+
1250
+ ALL_SOURCE stores no CREATE header, so a body read back with `src` comes
1251
+ back bare and the keyword is the easiest thing in Oracle to lose — and
1252
+ Oracle refuses to *change* the property through CREATE OR REPLACE, so the
1253
+ statement cannot succeed. Left alone, ORA-38824 lands after the developer
1254
+ has already approved the preview. Nothing here is guessed:
1255
+ ALL_OBJECTS.EDITIONABLE is the answer, and the edit is announced.
1256
+
1257
+ Returns (statement, note). The file on disk is never touched."""
1258
+ if editionable not in ("Y", "N"):
1259
+ return stmt, None # a new object, or a type that has no edition
1260
+ body = skip_leading_noise(stmt)
1261
+ m = CREATE_HEAD_RE.match(body)
1262
+ if not m:
1263
+ return stmt, None
1264
+ have = (m.group(2) or "").strip().upper()
1265
+ if editionable == "N":
1266
+ want = "NONEDITIONABLE"
1267
+ else:
1268
+ # only NONEDITIONABLE contradicts an editionable object; a bare header
1269
+ # and an explicit EDITIONABLE both say the same thing to Oracle
1270
+ want = "" if have.endswith("EDITIONABLE") and have != "EDITIONABLE" else have
1271
+ if have == want:
1272
+ return stmt, None
1273
+ head = stmt[:len(stmt) - len(body)]
1274
+ fixed = head + m.group(1) + (want + " " if want else "") + body[m.end():]
1275
+ verb = "added" if want else "removed"
1276
+ # two lines, indented like the other preview warnings: one long line wraps
1277
+ # into noise in a terminal
1278
+ return fixed, (f"NONEDITIONABLE {verb} to match the object in the "
1279
+ "database — Oracle cannot\n change that property through "
1280
+ "CREATE OR REPLACE (ORA-38824). Your file is unchanged.")
1281
+
1282
+
1243
1283
  def create_prefix(after_text):
1244
1284
  """The CREATE header ALL_SOURCE does not store, rebuilt for the version
1245
1285
  held in the database. The editionable keyword is read off the file being
@@ -1253,6 +1293,54 @@ def create_prefix(after_text):
1253
1293
  return "CREATE OR REPLACE " + (f"{m.group(1).upper()} " if m else "")
1254
1294
 
1255
1295
 
1296
+ def source_body(after_text):
1297
+ """The ALL_SOURCE-shaped half of a stored statement. create_prefix builds
1298
+ the header ALL_SOURCE does not keep; this drops it again, plus the
1299
+ SQL*Plus terminator, so the two can be compared as equals."""
1300
+ body = skip_leading_noise(prepare_statement(after_text, "plsql_source"))
1301
+ m = CREATE_HEAD_RE.match(body)
1302
+ return body[m.end():] if m else body
1303
+
1304
+
1305
+ def drift_note(db_source, last_applied, entry):
1306
+ """Whether the object moved since pythia last wrote it.
1307
+
1308
+ The snapshot proves what pythia did; nothing ever proved what anyone else
1309
+ did. In the field a comment line was written, read back intact two seconds
1310
+ later, and gone eight minutes on with no journal entry in between — and
1311
+ the developer went hunting a lying `src` instead of a second writer. The
1312
+ journal held both halves of the answer the whole time and nothing compared
1313
+ them. Warn, never refuse: editing by hand is legitimate, not noticing is
1314
+ not."""
1315
+ if not last_applied or not db_source.strip():
1316
+ return None
1317
+ mine = source_body(last_applied).replace("\r\n", "\n").rstrip()
1318
+ now = db_source.replace("\r\n", "\n").rstrip()
1319
+ if mine == now:
1320
+ return None
1321
+ delta = len(now.splitlines()) - len(mine.splitlines())
1322
+ how = (f"{abs(delta)} line{'s' if abs(delta) != 1 else ''} "
1323
+ + ("more" if delta > 0 else "fewer")) if delta else "the same line count, different text"
1324
+ return (f"This object changed outside pythia since {entry} ({how}). The "
1325
+ "diff below is\n against the database as it stands now, not "
1326
+ "against what pythia wrote.")
1327
+
1328
+
1329
+ def last_applied_after(root, obj_type, name):
1330
+ """The text pythia last actually wrote to this object, from the journal."""
1331
+ for eid in list_journal_entries(root):
1332
+ try:
1333
+ e = read_journal_entry(root, eid)
1334
+ except SystemExit:
1335
+ continue
1336
+ meta = e["meta"]
1337
+ if (meta.get("applied")
1338
+ and str(meta.get("object", "")).upper() == name.upper()
1339
+ and str(meta.get("type", "")).upper() == obj_type.upper()):
1340
+ return eid, e["after"]
1341
+ return None, None
1342
+
1343
+
1256
1344
  def render_restore(obj_type, name, before_text, after_text=""):
1257
1345
  """The statement that puts things back. For an object that did not exist,
1258
1346
  undo means DROP — a genuinely different promise than restoring source, so
@@ -2046,6 +2134,7 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2046
2134
  "invocation's connection, which is now over.")
2047
2135
  return 0
2048
2136
 
2137
+ edition_note = None
2049
2138
  if group == "plsql_source":
2050
2139
  otype, name, file_schema = parse_object(file_text)
2051
2140
  if file_schema and file_schema.upper() != schema:
@@ -2056,6 +2145,9 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2056
2145
  _, occ_rows = run_query(conn, load_query("name-occupants.sql"),
2057
2146
  {"s": schema, "n": name})
2058
2147
  blockers = name_conflicts(otype, [r[0] for r in occ_rows])
2148
+ stmt, edition_note = reconcile_editionable(
2149
+ stmt, next((cell(r[1]) for r in occ_rows
2150
+ if str(r[0]).upper() == otype.upper()), None))
2059
2151
  if blockers:
2060
2152
  sys.exit(f"{name} already exists as {', '.join(blockers)} in {schema} "
2061
2153
  "— CREATE OR REPLACE cannot change an object's type "
@@ -2112,12 +2204,17 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2112
2204
  "tty": human_at_the_keyboard(),
2113
2205
  "invalid_before": invalid_before, **(origin or {})}
2114
2206
  entry = write_journal_entry(ns.project_root, otype, name, db_source,
2115
- file_text, meta)
2207
+ stmt if edition_note else file_text, meta)
2116
2208
  # "created" is about an OBJECT that did not exist. A DML/DDL statement has
2117
2209
  # no object identity, so it is never "new" — and saying so would promise a
2118
2210
  # DROP-shaped undo that does not exist for those groups.
2119
2211
  created = not db_source.strip() and group == "plsql_source"
2120
2212
 
2213
+ drift = None
2214
+ if group == "plsql_source" and db_source.strip():
2215
+ eid, last = last_applied_after(ns.project_root, otype, name)
2216
+ drift = drift_note(db_source, last, eid)
2217
+
2121
2218
  # 2. IMPACT
2122
2219
  summary = ""
2123
2220
  if group == "plsql_source":
@@ -2140,7 +2237,9 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2140
2237
  "created": created, "changed_lines": changed,
2141
2238
  "summary": summary, "warning": warn,
2142
2239
  "row_set": row_set,
2143
- "naming_warning": style, "token": token,
2240
+ "naming_warning": style,
2241
+ "editionable_note": edition_note, "drift": drift,
2242
+ "token": token,
2144
2243
  "journal": entry, "will_apply": confirmed}))
2145
2244
  else:
2146
2245
  en = getattr(ns, "color", False)
@@ -2164,6 +2263,10 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2164
2263
  elif group == "data_dml":
2165
2264
  print("\n INSERT — no rows exist beforehand, so there is no row "
2166
2265
  "set to revalidate.")
2266
+ if drift:
2267
+ print(f"\n {paint('! ' + drift, 'yellow', en)}")
2268
+ if edition_note:
2269
+ print(f"\n {paint('! ' + edition_note, 'yellow', en)}")
2167
2270
  if warn:
2168
2271
  print(f"\n {paint(warn, 'yellow', en)}")
2169
2272
  if style:
@@ -2203,7 +2306,15 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2203
2306
  cur.execute("ALTER SESSION SET plscope_settings = "
2204
2307
  "'IDENTIFIERS:ALL, STATEMENTS:ALL'")
2205
2308
  with conn.cursor() as cur:
2206
- cur.execute(stmt)
2309
+ try:
2310
+ cur.execute(stmt)
2311
+ except Exception as e: # noqa: BLE001 - the driver refused
2312
+ # the preview and "Snapshot saved" printed before this line ran,
2313
+ # so without a closing verdict the run reads like one that landed
2314
+ sys.exit(f"FAILED — nothing was written.\n{e}\n"
2315
+ "The object is unchanged. The approval is unspent: retry "
2316
+ "the same file with the same token, or fix the file and "
2317
+ "preview again for a new one.")
2207
2318
  affected = cur.rowcount if group == "data_dml" else None
2208
2319
  if group == "data_dml":
2209
2320
  # the last-millisecond check: the probe ran before this statement, and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.11.2
3
+ Version: 0.12.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
@@ -23,6 +23,7 @@ scripts/pythia_plsql.egg-info/entry_points.txt
23
23
  scripts/pythia_plsql.egg-info/requires.txt
24
24
  scripts/pythia_plsql.egg-info/top_level.txt
25
25
  skills/pythia-apply/SKILL.md
26
+ skills/pythia-comment/SKILL.md
26
27
  skills/pythia-conventions/SKILL.md
27
28
  skills/pythia-explore/SKILL.md
28
29
  skills/pythia-explore/reference/data-dictionary.md
@@ -10,8 +10,7 @@ description: Use when a PL/SQL change is ready to reach the database - applying
10
10
  **Phase:** Ask → Do — the preview is relayed and approved before the one write door opens
11
11
 
12
12
  DDL in Oracle commits itself, so the snapshot pythia takes before writing is
13
- the only undo there is — and for DML not even that. This skill exists so that
14
- net is always used, and used honestly.
13
+ the only undo there is — for DML not even that. This skill is how it gets used.
15
14
 
16
15
  ## The Iron Law
17
16
 
@@ -54,20 +53,21 @@ confirms a number you already knew.
54
53
  warning, exactly as printed. The developer approves what they see, not
55
54
  your paraphrase.
56
55
  3. **Ask for approval — one question per token.** `pythia approve --card
57
- <token>` prints the card; ask an `AskUserQuestion` whose text is that card
58
- verbatim (header `pythia`; options exactly `Approve` / `Reject`, no
59
- "(Recommended)"; up to four tokens per call). `Approve` mints the grant via
60
- the hook. Anything else — `Reject`, free text, silence, an earlier preview's
61
- yes — is not approval: stop and ask what should change. File changed → step 1.
62
- No hook installed? Relay `pythia approve <token>` for their own terminal.
56
+ <token>` prints the card; the `AskUserQuestion` text is that card and
57
+ nothing else — no word before it, no translation after it, no framing of
58
+ your own. The hook compares question to card, mints nothing when they
59
+ differ, and by then the developer has already clicked. Put the explanation
60
+ in the option descriptions; the hook does not read those. Header `pythia`;
61
+ options exactly `Approve` / `Reject`, no "(Recommended)"; up to four tokens
62
+ per call. Anything but `Approve` is not approval: stop and ask what should
63
+ change. File changed → step 1. No hook? Relay `pythia approve <token>`.
63
64
  4. **Apply** by running the exact `then the agent:` line pythia printed. Two
64
65
  refusals are normal here, and neither is a malfunction:
65
66
  - *"no developer approval is on file"* — the answer was not `Approve`, or
66
67
  the hook is not installed. Say so and wait; retrying does not create it.
67
68
  - *"the confirmation token does not match"* — the file or the database
68
- changed since the preview. Go back to step 1, never "retry". If the fresh
69
- preview's before-side differs from what you last saw, say so: someone may
70
- have changed the object on this shared database.
69
+ changed since the preview. Go back to step 1, never "retry"; the fresh
70
+ preview warns by itself when the object moved outside pythia.
71
71
  5. **Read the exit code — it is the verdict. Never through a pipe:**
72
72
  `apply … | tail` returns *tail's* code, always 0. Unpiped, or `${PIPESTATUS[0]}`.
73
73
 
@@ -135,7 +135,7 @@ affected count and up to ten rows, and `--confirm` refuses if that set moved.
135
135
  | "I'll restore quietly to clean up my mistake" | Restores are writes. Same gate, same visibility. |
136
136
  | "`$?` said 0 after I piped to tail" | That was tail's 0. Read pythia's own words, or its unpiped code. |
137
137
  | "I'll run approve myself to unblock this" | The developer's act. Console approve refuses you; the hook mints only from *their* answer. Routing around it is what this gate exists to stop. |
138
- | "I'll summarise the card so the question reads nicer" | The hook refuses a paraphrase — the developer must approve pythia's words, not yours. Paste the card verbatim. |
138
+ | "I'll add a line of my own around the card" | Any added word voids it, and the refusal arrives after they have clicked. Explanation goes in the option description. |
139
139
 
140
140
  ## When NOT to use this skill
141
141
 
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: pythia-comment
3
+ description: Use when you are about to write or edit a comment in PL/SQL you are changing, or in a .sql deliverable headed for the database. One line, one fixed shape, and anything longer goes where it belongs instead. It fires on the act of commenting rather than on a request - catching yourself opening a second comment line above one statement is the trigger.
4
+ ---
5
+
6
+ # Comments - one line, or somewhere else
7
+
8
+ **Announce at start:** "Using pythia-comment — one line, date - author - what changed."
9
+
10
+ **Phase:** Do — it applies while writing, before `pythia apply` sees the file
11
+
12
+ A comment is the only part of a change no compiler checks and no test covers,
13
+ so it is the part that rots. One line survives the next rewrite. A paragraph
14
+ becomes a claim the next reader has to disprove before they can work.
15
+
16
+ ## The shape
17
+
18
+ ```sql
19
+ -- 11/09/2026 - jdoe - source flag reads the mapping table, not the join
20
+ ```
21
+
22
+ `date - author - what changed`, with the date as `DD/MM/YYYY`. The author is
23
+ the developer's handle, never the agent's: the agent did not decide this, and
24
+ a blame trail that names a tool is a dead end.
25
+
26
+ `--` for PL/SQL and SQL. That is the whole surface this skill covers — what
27
+ pythia applies.
28
+
29
+ **ASCII only.** Parts of the chain around a .sql file still read it as ANSI,
30
+ and a diacritic there becomes mojibake in something the database keeps
31
+ forever. Vietnamese without diacritics is a house style for exactly this
32
+ reason, and it is a fine one.
33
+
34
+ ## One line
35
+
36
+ If it needs a second line, the comment is the wrong container. Move it:
37
+
38
+ | What you were about to write | Where it goes |
39
+ |---|---|
40
+ | why this shape, and what breaks without it | the header block of the .sql deliverable |
41
+ | a rule that must not regress | a test or verifier, as an assertion with a message |
42
+ | the decision, and the alternatives you rejected | the spec, or the ticket |
43
+ | what changed and why, for a reader of history | the commit message |
44
+ | what the statement does | nowhere — the statement already says it |
45
+
46
+ Comment only what the code cannot show: a trap, a decision, a why.
47
+
48
+ ## Do not
49
+
50
+ - Multi-line explanatory blocks above one statement.
51
+ - Banners, box drawing, `-- ==== section ====`.
52
+ - Bullet lists, quoted error codes, revision archaeology inline.
53
+ - Commented-out code kept as documentation. Delete it — the journal and the
54
+ VCS both remember, and neither of them lies about what is live.
55
+ - Touching comments on code you are not changing. That is a different diff,
56
+ and it buries yours.
57
+
58
+ ## Before you finish
59
+
60
+ Count the comment lines you added. More than one per statement is a rewrite,
61
+ not a trim.
62
+
63
+ Then confirm the comment actually reached the database. A comment is exactly
64
+ the kind of line a second writer drops without noticing, and it costs nothing
65
+ to check:
66
+
67
+ ```sql
68
+ select count(*) from all_source
69
+ where owner = :owner and name = :name and text like :marker
70
+ ```
71
+
72
+ The preview's *changed outside pythia* warning is the other half of that
73
+ check — it fires when the object moved since pythia last wrote it.
@@ -42,7 +42,8 @@ existing ones — not to introduce a better style.
42
42
  `unistr('...')`, so the text survives every client/DB charset exactly.
43
43
  5. **Check yourself before handing off.** Reread against
44
44
  `reference/patterns.md` — cursor and bulk patterns, exception discipline,
45
- bind variables, commit ownership.
45
+ bind variables, commit ownership. Every comment you wrote or touched goes
46
+ through `pythia-comment` — one line, or it belongs somewhere else.
46
47
 
47
48
  ## Scope is the developer's sentence, not yours
48
49
 
@@ -30,6 +30,7 @@ sessions drift: some ask, some silently decide.
30
30
  | "our standards / adopt this base's style" | `pythia-conventions` | |
31
31
  | "make a skill for how we do X" | `pythia-skill-author` | |
32
32
  | setup, connection failures, privilege warnings | `pythia-setup` | |
33
+ | about to write a comment in code you are changing | `pythia-comment` — one line, or it belongs somewhere else | |
33
34
  | an ORA-/PLS- error to investigate | `pythia-explore` (`errors`, `src`) | `pythia-review` |
34
35
 
35
36
  Priority when several apply: **process before construction** — spec before
@@ -249,7 +249,8 @@ OLD_SRC = "PACKAGE BODY pkg_order AS\n old line;\nEND;\n"
249
249
  NEW_FILE = "CREATE OR REPLACE PACKAGE BODY pkg_order AS\n new line;\nEND;\n/\n"
250
250
 
251
251
 
252
- def base_script(errors=(), invalid_after=None, db_source=OLD_SRC):
252
+ def base_script(errors=(), invalid_after=None, db_source=OLD_SRC,
253
+ editionable="Y"):
253
254
  invalid_before = [("X_ALREADY_BROKEN", "PROCEDURE")]
254
255
  return {
255
256
  "from all_source": ([("TEXT",)], [(ln + "\n",) for ln in db_source.splitlines()]),
@@ -268,7 +269,8 @@ def base_script(errors=(), invalid_after=None, db_source=OLD_SRC):
268
269
  "from session_privs": ([("PRIVILEGE",)], []),
269
270
  "current_schema": ([("CURRENT_SCHEMA",)], [("APP",)]),
270
271
  # main-namespace occupants of the target name (type-conflict check)
271
- "object_name = upper(:n)": ([("OBJECT_TYPE",)], [("PACKAGE BODY",)]),
272
+ "object_name = upper(:n)": ([("OBJECT_TYPE", "EDITIONABLE")],
273
+ [("PACKAGE BODY", editionable)]),
272
274
  }
273
275
 
274
276
 
@@ -447,6 +449,87 @@ def test_insert_needs_no_row_set():
447
449
  assert pythia.read_journal_entry(td, entry)["meta"]["row_set"] is None
448
450
 
449
451
 
452
+ def test_reconcile_editionable_matches_the_database():
453
+ """ALL_SOURCE stores no CREATE header, so a body read back with `src`
454
+ comes back bare and the keyword is the easiest thing in Oracle to lose.
455
+ Oracle will not change the property through CREATE OR REPLACE, so the
456
+ statement has to agree with the object before it runs."""
457
+ r = pythia.reconcile_editionable
458
+ bare = "CREATE OR REPLACE PROCEDURE p AS BEGIN NULL; END;"
459
+ non = "CREATE OR REPLACE NONEDITIONABLE PROCEDURE p AS BEGIN NULL; END;"
460
+ stmt, note = r(bare, "N")
461
+ assert stmt == non and "NONEDITIONABLE" in note
462
+ stmt, note = r(non, "Y")
463
+ assert stmt == bare and "NONEDITIONABLE" in note
464
+ # already agreed: untouched, and nothing to announce
465
+ assert r(non, "N") == (non, None)
466
+ assert r(bare, "Y") == (bare, None)
467
+ # no property to match: a new object, or a type that cannot be editioned
468
+ assert r(bare, None) == (bare, None)
469
+ # the old misspelling is not the property Oracle holds either
470
+ stmt, _ = r("create or replace NOEDITIONABLE procedure p as begin null; end;", "N")
471
+ assert stmt.startswith("create or replace NONEDITIONABLE ")
472
+ # leading comments keep their place
473
+ stmt, _ = r("-- header\ncreate or replace procedure p as begin null; end;", "N")
474
+ assert stmt.startswith("-- header\ncreate or replace NONEDITIONABLE ")
475
+
476
+
477
+ def test_apply_writes_the_editionable_keyword_the_database_holds():
478
+ """The whole point is that ORA-38824 never reaches the developer: they
479
+ approved a preview, and the write has to be able to succeed."""
480
+ with tempfile.TemporaryDirectory() as td:
481
+ tok = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", NEW_FILE, OLD_SRC)
482
+ pythia.mint_grant(td, tok, "DEV")
483
+ conn = FakeConn(base_script(editionable="N"))
484
+ code = pythia.run_apply(conn, "APP", apply_ns(td, confirm=tok), NEW_FILE)
485
+ assert code == 0
486
+ ddl = wrote_ddl(conn)
487
+ assert len(ddl) == 1 and ddl[0].lstrip().upper().startswith(
488
+ "CREATE OR REPLACE NONEDITIONABLE PACKAGE BODY"), ddl
489
+ # the journal records what ran, so the undo keeps the property too
490
+ e = pythia.read_journal_entry(td, pythia.list_journal_entries(td)[0])
491
+ assert "NONEDITIONABLE" in e["after"]
492
+ assert e["restore"].upper().startswith("CREATE OR REPLACE NONEDITIONABLE")
493
+
494
+
495
+ def test_drift_note_fires_only_when_the_object_moved():
496
+ """The field case: pythia wrote 118 lines, read them back two seconds
497
+ later, and eight minutes on the database held 117 — with no journal entry
498
+ in between. `src` was honest both times; nothing ever compared them."""
499
+ mine = "CREATE OR REPLACE PACKAGE BODY pkg_order AS\n a;\n b;\nEND;\n"
500
+ same = "PACKAGE BODY pkg_order AS\n a;\n b;\nEND;"
501
+ assert pythia.drift_note(same, mine, "E1") is None
502
+ # trailing whitespace is not drift
503
+ assert pythia.drift_note(same + "\n\n", mine, "E1") is None
504
+ assert pythia.drift_note(same.replace("\n", "\r\n"), mine, "E1") is None
505
+ # a line gone is
506
+ note = pythia.drift_note("PACKAGE BODY pkg_order AS\n a;\nEND;", mine, "E1")
507
+ assert note and "E1" in note and "1 line" in note
508
+ # nothing to compare against
509
+ assert pythia.drift_note(same, "", "E1") is None
510
+ assert pythia.drift_note(same, None, None) is None
511
+
512
+
513
+ def test_preview_warns_when_the_object_changed_outside_pythia():
514
+ with tempfile.TemporaryDirectory() as td:
515
+ # pythia applied this text once
516
+ pythia.write_journal_entry(
517
+ td, "PACKAGE BODY", "PKG_ORDER", "older\n",
518
+ "CREATE OR REPLACE " + OLD_SRC,
519
+ {"token": "aaaaaa", "connection": "DEV", "schema": "APP",
520
+ "group": "plsql_source", "applied": True})
521
+ # ...and the database now holds something else
522
+ moved = OLD_SRC.replace(" old line;\n", "")
523
+ import contextlib
524
+ import io
525
+ buf = io.StringIO()
526
+ with contextlib.redirect_stdout(buf):
527
+ pythia.run_apply(FakeConn(base_script(db_source=moved)), "APP",
528
+ apply_ns(td), NEW_FILE)
529
+ out = buf.getvalue()
530
+ assert "outside pythia" in out and "1 line" in out, out
531
+
532
+
450
533
  def test_apply_preview_writes_nothing_and_gives_token():
451
534
  with tempfile.TemporaryDirectory() as td:
452
535
  conn = FakeConn(base_script())
@@ -473,6 +556,45 @@ def test_apply_correct_token_writes_and_verifies_clean():
473
556
  assert e["meta"]["applied"] is True
474
557
 
475
558
 
559
+ class ExplodingConn(FakeConn):
560
+ """The driver refuses the write. ORA-38824 on an editions-enabled schema
561
+ is the case that prompted this: by the time it lands, the preview and
562
+ "Snapshot saved" have already printed, so the run reads like a success."""
563
+
564
+ def __init__(self, script, error="ORA-38824: cannot change the EDITIONABLE "
565
+ "property of an existing object"):
566
+ super().__init__(script)
567
+ self.error = error
568
+
569
+ def cursor(self):
570
+ cur = super().cursor()
571
+ passthrough = cur.execute
572
+
573
+ def execute(sql, binds=None):
574
+ if sql.lstrip().lower().startswith("create"):
575
+ raise RuntimeError(self.error)
576
+ return passthrough(sql, binds)
577
+
578
+ cur.execute = execute
579
+ return cur
580
+
581
+
582
+ def test_failed_apply_ends_in_a_verdict_and_keeps_the_approval():
583
+ """A write that the driver refused must not read like one that landed:
584
+ the last word is the failure, and it says nothing was written."""
585
+ with tempfile.TemporaryDirectory() as td:
586
+ tok = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", NEW_FILE, OLD_SRC)
587
+ pythia.mint_grant(td, tok, "DEV")
588
+ conn = ExplodingConn(base_script())
589
+ expect_exit(lambda: pythia.run_apply(conn, "APP", apply_ns(td, confirm=tok),
590
+ NEW_FILE),
591
+ "failed", "nothing was written", "ORA-38824")
592
+ eid = pythia.list_journal_entries(td)[0]
593
+ assert pythia.read_journal_entry(td, eid)["meta"]["applied"] is False
594
+ # one approval, one write — and there was no write, so it still stands
595
+ assert pythia.read_grant(td, tok)["used_at"] is None
596
+
597
+
476
598
  def test_apply_stale_token_refused():
477
599
  with tempfile.TemporaryDirectory() as td:
478
600
  tok = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", "something else", OLD_SRC)
@@ -538,7 +660,7 @@ def test_apply_snapshot_survives_failed_execute():
538
660
  conn = Exploding(base_script())
539
661
  try:
540
662
  pythia.run_apply(conn, "APP", apply_ns(td, confirm=tok), NEW_FILE)
541
- except RuntimeError:
663
+ except SystemExit: # the driver error, now a closing verdict
542
664
  pass
543
665
  ids = pythia.list_journal_entries(td)
544
666
  assert len(ids) == 1 # snapshot was already on disk
@@ -1518,7 +1640,9 @@ def test_apply_previews_a_noneditionable_object_end_to_end():
1518
1640
  f.write_text(src, encoding="utf-8")
1519
1641
  buf = io.StringIO()
1520
1642
  with contextlib.redirect_stdout(buf):
1521
- pythia.cmd_apply(FakeConn(base_script()), "APP",
1643
+ # the object really is NONEDITIONABLE in the database, which is
1644
+ # what makes the keyword in the file the right one
1645
+ pythia.cmd_apply(FakeConn(base_script(editionable="N")), "APP",
1522
1646
  apply_ns(td, file=str(f)))
1523
1647
  out = buf.getvalue()
1524
1648
  assert "more than one statement" not in out
@@ -13,7 +13,7 @@ SKILLS = ROOT / "skills"
13
13
  EXPECTED = {"pythia-setup", "pythia-explore", "pythia-impact",
14
14
  "pythia-write", "pythia-apply", "pythia-review",
15
15
  "pythia-skill-author", "pythia-conventions", "pythia-spec",
16
- "using-pythia"}
16
+ "pythia-comment", "using-pythia"}
17
17
 
18
18
  # spec: SKILL.md under 150 lines, detail pushed to reference/
19
19
  MAX_LINES = 150
@@ -153,7 +153,7 @@ def test_no_manifest_hardcodes_a_stale_skill_count():
153
153
  so the check is: no manifest states a number the code can disprove."""
154
154
  import json
155
155
  n = len(EXPECTED)
156
- words = {7: "seven", 8: "eight", 9: "nine"}
156
+ words = {7: "seven", 8: "eight", 9: "nine", 10: "ten", 11: "eleven"}
157
157
  stale = [w for k, w in words.items() if k != n]
158
158
  for rel in (".claude-plugin/marketplace.json", "npm/package.json",
159
159
  "npm/README.md"):
File without changes
File without changes
File without changes