pythia-plsql 0.10.0__tar.gz → 0.11.1__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 (44) hide show
  1. {pythia_plsql-0.10.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.11.1}/PKG-INFO +7 -4
  2. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/README.md +6 -3
  3. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/pyproject.toml +1 -1
  4. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/scripts/pythia.py +110 -7
  5. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1/scripts/pythia_plsql.egg-info}/PKG-INFO +7 -4
  6. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/tests/test_install.py +113 -0
  7. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/tests/test_phase3.py +58 -0
  8. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/LICENSE +0 -0
  9. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/compile-errors.sql +0 -0
  10. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/current-schema.sql +0 -0
  11. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/dependencies.sql +0 -0
  12. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/impact.sql +0 -0
  13. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/invalid-objects.sql +0 -0
  14. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/name-occupants.sql +0 -0
  15. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/object-names.sql +0 -0
  16. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/object-source.sql +0 -0
  17. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/plscope-enabled.sql +0 -0
  18. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/plscope-statements.sql +0 -0
  19. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/plscope-usages.sql +0 -0
  20. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/session-privileges.sql +0 -0
  21. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/similar-candidates.sql +0 -0
  22. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/queries/source.sql +0 -0
  23. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
  24. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  25. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  26. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  27. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  28. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/setup.cfg +0 -0
  29. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-apply/SKILL.md +0 -0
  30. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-conventions/SKILL.md +0 -0
  31. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-explore/SKILL.md +0 -0
  32. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  33. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-impact/SKILL.md +0 -0
  34. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-review/SKILL.md +0 -0
  35. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-review/reference/antipatterns.md +0 -0
  36. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-setup/SKILL.md +0 -0
  37. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-skill-author/SKILL.md +0 -0
  38. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-spec/SKILL.md +0 -0
  39. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-write/SKILL.md +0 -0
  40. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/pythia-write/reference/patterns.md +0 -0
  41. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/skills/using-pythia/SKILL.md +0 -0
  42. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/tests/test_phase1.py +0 -0
  43. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/tests/test_phase2.py +0 -0
  44. {pythia_plsql-0.10.0 → pythia_plsql-0.11.1}/tests/test_phase5.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.10.0
3
+ Version: 0.11.1
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
@@ -121,14 +121,14 @@ is in, it is doing none of them.
121
121
  ## Install
122
122
 
123
123
  ```bash
124
- npx pythia-plsql # everything: pip install + skills picker + config scaffold
124
+ npx pythia-plsql # everything: pip install + skills picker + config scaffold + hooks
125
125
  ```
126
126
 
127
127
  Or the same thing piecewise:
128
128
 
129
129
  ```bash
130
130
  pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
131
- python -m pythia install # skills into your agent + .pythia/ scaffold
131
+ python -m pythia install # skills into your agent + .pythia/ scaffold + Claude Code hooks
132
132
  pythia check # fill in connections.json first, then verify
133
133
  ```
134
134
 
@@ -204,7 +204,10 @@ hand with
204
204
  works just as well.
205
205
  `pythia check` warns when the session runs with more power than the task needs.
206
206
 
207
- Using Claude Code? [`examples/claude-code-settings.example.json`](examples/claude-code-settings.example.json)
207
+ Using Claude Code? `pythia install` wires the two hooks — the approve-in-chat
208
+ door and the session-start guide — and the deny rule into
209
+ `.claude/settings.json`, merged into whatever is already there. The rest of
210
+ [`examples/claude-code-settings.example.json`](examples/claude-code-settings.example.json)
208
211
  stops it prompting for the read-only commands and asks it to pause on
209
212
  writes — optional, and yours to install
210
213
  ([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
@@ -107,14 +107,14 @@ is in, it is doing none of them.
107
107
  ## Install
108
108
 
109
109
  ```bash
110
- npx pythia-plsql # everything: pip install + skills picker + config scaffold
110
+ npx pythia-plsql # everything: pip install + skills picker + config scaffold + hooks
111
111
  ```
112
112
 
113
113
  Or the same thing piecewise:
114
114
 
115
115
  ```bash
116
116
  pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
117
- python -m pythia install # skills into your agent + .pythia/ scaffold
117
+ python -m pythia install # skills into your agent + .pythia/ scaffold + Claude Code hooks
118
118
  pythia check # fill in connections.json first, then verify
119
119
  ```
120
120
 
@@ -190,7 +190,10 @@ hand with
190
190
  works just as well.
191
191
  `pythia check` warns when the session runs with more power than the task needs.
192
192
 
193
- Using Claude Code? [`examples/claude-code-settings.example.json`](examples/claude-code-settings.example.json)
193
+ Using Claude Code? `pythia install` wires the two hooks — the approve-in-chat
194
+ door and the session-start guide — and the deny rule into
195
+ `.claude/settings.json`, merged into whatever is already there. The rest of
196
+ [`examples/claude-code-settings.example.json`](examples/claude-code-settings.example.json)
194
197
  stops it prompting for the read-only commands and asks it to pause on
195
198
  writes — optional, and yours to install
196
199
  ([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.10.0"
7
+ version = "0.11.1"
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" }
@@ -713,7 +713,12 @@ def plscope_message(name, has_any_data):
713
713
  GROUPS = ("plsql_source", "data_dml", "structural", "grants", "session")
714
714
 
715
715
  PLSQL_SOURCE_RE = re.compile(
716
- r"^create\s+(?:or\s+replace\s+)?(?:(?:no)?editionable\s+|(?:no)?force\s+)*"
716
+ # NONEDITIONABLE, not "noeditionable": Oracle's keyword has the middle N,
717
+ # and missing it dropped every NONEDITIONABLE object into structural —
718
+ # deny by default, and counted by semicolons, so a PL/SQL body read as
719
+ # "more than one statement". Both spellings are accepted here; only the
720
+ # database decides whether the statement is valid.
721
+ r"^create\s+(?:or\s+replace\s+)?(?:(?:non?)?editionable\s+|(?:no)?force\s+)*"
717
722
  r"(procedure|function|package\s+body|package|trigger|view|type\s+body|type)\b",
718
723
  re.I | re.S)
719
724
 
@@ -1231,12 +1236,29 @@ def prune_expired_grants(root, now=None):
1231
1236
  return gone
1232
1237
 
1233
1238
 
1234
- def render_restore(obj_type, name, before_text):
1239
+ EDITIONABLE_KW_RE = re.compile(
1240
+ r"^create\s+(?:or\s+replace\s+)?((?:non?)?editionable)\b", re.I)
1241
+
1242
+
1243
+ def create_prefix(after_text):
1244
+ """The CREATE header ALL_SOURCE does not store, rebuilt for the version
1245
+ held in the database. The editionable keyword is read off the file being
1246
+ applied, because the database's own answer is not in ALL_SOURCE and
1247
+ Oracle refuses to *change* the property through CREATE OR REPLACE
1248
+ (ORA-38824) — so a file that applies cleanly already spells the property
1249
+ the object has. Getting this wrong costs twice: a restore that fails
1250
+ exactly when it is needed, and a phantom line in the diff the developer
1251
+ approves."""
1252
+ m = EDITIONABLE_KW_RE.match(skip_leading_noise(after_text))
1253
+ return "CREATE OR REPLACE " + (f"{m.group(1).upper()} " if m else "")
1254
+
1255
+
1256
+ def render_restore(obj_type, name, before_text, after_text=""):
1235
1257
  """The statement that puts things back. For an object that did not exist,
1236
1258
  undo means DROP — a genuinely different promise than restoring source, so
1237
1259
  the caller records created=True and the report says it plainly."""
1238
1260
  if before_text.strip():
1239
- return "CREATE OR REPLACE " + before_text.rstrip() + "\n"
1261
+ return create_prefix(after_text) + before_text.rstrip() + "\n"
1240
1262
  return f"DROP {obj_type} {name}\n"
1241
1263
 
1242
1264
 
@@ -1256,7 +1278,7 @@ def write_journal_entry(root, obj_type, name, before, after, meta, now=None):
1256
1278
  d.mkdir(parents=True)
1257
1279
  (d / "before.sql").write_text(before, encoding="utf-8")
1258
1280
  (d / "after.sql").write_text(after, encoding="utf-8")
1259
- (d / "restore.sql").write_text(render_restore(obj_type, name, before),
1281
+ (d / "restore.sql").write_text(render_restore(obj_type, name, before, after),
1260
1282
  encoding="utf-8")
1261
1283
  full = {"object": name, "type": obj_type, "created": not before.strip(),
1262
1284
  "entry": eid, **meta}
@@ -2105,8 +2127,10 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2105
2127
 
2106
2128
  # 3. PREVIEW — diff like against like: ALL_SOURCE never stores the
2107
2129
  # CREATE OR REPLACE header, so prepend it before comparing, or an
2108
- # unchanged object would show a phantom two-line change forever.
2109
- base = ("CREATE OR REPLACE " + db_source) if db_source.strip() else ""
2130
+ # unchanged object would show a phantom two-line change forever. The
2131
+ # editionable keyword is part of that header, or a NONEDITIONABLE file
2132
+ # shows its own first line as a change that is not one.
2133
+ base = (create_prefix(stmt) + db_source) if db_source.strip() else ""
2110
2134
  diff_text, changed = render_diff(base, stmt)
2111
2135
  warn = privilege_warning(conn, schema, ns.conn_user)
2112
2136
  style = (naming_violation(otype, name, load_conventions(ns.project_root))
@@ -3143,6 +3167,78 @@ def clean_legacy_skills(base_dir):
3143
3167
  _remove_link_first(pathlib.Path(base_dir) / rel / old_name)
3144
3168
 
3145
3169
 
3170
+ # What `install` wires into Claude Code's settings.json: the two hooks and
3171
+ # the deny rule, verbatim from examples/claude-code-settings.example.json (a
3172
+ # test keeps them equal). The allow list and autoMode stay in the example —
3173
+ # those are the developer's permission preferences, not pythia's to set.
3174
+ CLAUDE_HOOKS = {
3175
+ "SessionStart": [{"hooks": [
3176
+ {"type": "command", "command": "python -m pythia guide --brief"}]}],
3177
+ "PostToolUse": [{"matcher": "AskUserQuestion", "hooks": [
3178
+ {"type": "command", "command": "python -m pythia approve --hook",
3179
+ "timeout": 15}]}],
3180
+ }
3181
+ CLAUDE_DENY = ["Bash(pythia approve --hook*)",
3182
+ "Bash(python -m pythia approve --hook*)"]
3183
+ # a hook already present in any spelling (python3, the plugin's script path)
3184
+ # is recognised by what it runs, not by how it invokes it
3185
+ HOOK_MARKS = {"SessionStart": "guide --brief", "PostToolUse": "approve --hook"}
3186
+
3187
+
3188
+ def install_claude_hooks(base_dir, events=None):
3189
+ """Merge the two hooks and the deny rule into <base>/.claude/settings.json
3190
+ — the project's, or the home directory's for -g. The file is Claude
3191
+ Code's, so every other key survives and a pythia hook already there in
3192
+ any spelling counts as present. Returns (path, added): the pieces
3193
+ written, [] when all were there, None when the file could not be parsed
3194
+ and was therefore left alone. `events` narrows which hooks: -g writes
3195
+ only PostToolUse, since ~/.claude/settings.json applies to every project
3196
+ and the session guide would be noise outside Oracle work."""
3197
+ path = pathlib.Path(base_dir) / ".claude" / "settings.json"
3198
+ try:
3199
+ s = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
3200
+ hooks = s.setdefault("hooks", {})
3201
+ deny = s.setdefault("permissions", {}).setdefault("deny", [])
3202
+ added = []
3203
+ for event in events or CLAUDE_HOOKS:
3204
+ entries = CLAUDE_HOOKS[event]
3205
+ present = hooks.setdefault(event, [])
3206
+ cmds = " ".join(h.get("command", "")
3207
+ for e in present for h in e.get("hooks", []))
3208
+ if HOOK_MARKS[event] not in cmds:
3209
+ present.extend(json.loads(json.dumps(entries))) # a copy
3210
+ added.append(f"hooks.{event}")
3211
+ missing = [d for d in CLAUDE_DENY if d not in deny]
3212
+ if missing:
3213
+ deny.extend(missing)
3214
+ added.append("permissions.deny")
3215
+ except (ValueError, TypeError, AttributeError):
3216
+ return path, None
3217
+ if added:
3218
+ path.parent.mkdir(parents=True, exist_ok=True)
3219
+ path.write_text(json.dumps(s, indent=2, ensure_ascii=False) + "\n",
3220
+ encoding="utf-8")
3221
+ return path, added
3222
+
3223
+
3224
+ def report_claude_hooks(base_dir, ns, events=None):
3225
+ """install's last step, in both scopes: wire the hooks and say what
3226
+ happened. --no-hooks leaves Claude Code's file alone."""
3227
+ if getattr(ns, "no_hooks", False):
3228
+ return
3229
+ path, added = install_claude_hooks(base_dir, events)
3230
+ if added is None:
3231
+ print(f"\n! {path} is not valid JSON, so it was left untouched. Fix "
3232
+ f"it and re-run {invocation()} install, or paste the hooks "
3233
+ "block from GUIDE.md section 11.")
3234
+ elif added:
3235
+ print()
3236
+ print(f"Wired Claude Code hooks into {path}: {', '.join(added)}.")
3237
+ print("Restart the Claude Code session for them to load.")
3238
+ else:
3239
+ print(f"\nClaude Code hooks already in {path}.")
3240
+
3241
+
3146
3242
  def cmd_install(conn, schema, ns):
3147
3243
  import shutil
3148
3244
  en = getattr(ns, "color", False)
@@ -3158,6 +3254,9 @@ def cmd_install(conn, schema, ns):
3158
3254
  sys.exit(code)
3159
3255
  else:
3160
3256
  clean_legacy_skills(pathlib.Path.home())
3257
+ # machine-wide: the approve hook is silent without a pythia token;
3258
+ # the session guide would be noise in every other project
3259
+ report_claude_hooks(pathlib.Path.home(), ns, events=("PostToolUse",))
3161
3260
  print("\nGlobal skills serve every project — per-project installs "
3162
3261
  "will now skip the skills step automatically.")
3163
3262
  return
@@ -3180,6 +3279,7 @@ def cmd_install(conn, schema, ns):
3180
3279
  sys.exit(code)
3181
3280
  else:
3182
3281
  clean_legacy_skills(ns.project_root)
3282
+ report_claude_hooks(ns.project_root, ns)
3183
3283
  print(f"\nNext: fill in {path}")
3184
3284
  print(f"Then: {invocation()} check")
3185
3285
  scripts_dir = installed_scripts_dir()
@@ -3348,13 +3448,16 @@ def build_parser():
3348
3448
  s.add_argument("--loi", action="store_true",
3349
3449
  help="wrap as 'loi:'||unistr(...)||':loi'")
3350
3450
  s = sub.add_parser("install", parents=[common()],
3351
- help="install the skill pack and scaffold .pythia/ config")
3451
+ help="install the skill pack, scaffold .pythia/ config, wire the Claude Code hooks")
3352
3452
  s.add_argument("-g", "--global", dest="glob", action="store_true",
3353
3453
  help="install the skill pack machine-wide (serves every "
3354
3454
  "project; per-project installs then skip skills)")
3355
3455
  s.add_argument("--add-to-path", action="store_true", dest="add_to_path",
3356
3456
  help="put the pythia command on your PATH without asking "
3357
3457
  "(Windows; user PATH only)")
3458
+ s.add_argument("--no-hooks", action="store_true", dest="no_hooks",
3459
+ help="leave Claude Code's .claude/settings.json alone "
3460
+ "(no approve-in-chat hook, no session-start guide)")
3358
3461
  s.add_argument("--source", default=DEFAULT_SKILLS_SOURCE,
3359
3462
  help="skills repo for `npx skills add` "
3360
3463
  f"(default {DEFAULT_SKILLS_SOURCE})")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.10.0
3
+ Version: 0.11.1
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
@@ -121,14 +121,14 @@ is in, it is doing none of them.
121
121
  ## Install
122
122
 
123
123
  ```bash
124
- npx pythia-plsql # everything: pip install + skills picker + config scaffold
124
+ npx pythia-plsql # everything: pip install + skills picker + config scaffold + hooks
125
125
  ```
126
126
 
127
127
  Or the same thing piecewise:
128
128
 
129
129
  ```bash
130
130
  pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
131
- python -m pythia install # skills into your agent + .pythia/ scaffold
131
+ python -m pythia install # skills into your agent + .pythia/ scaffold + Claude Code hooks
132
132
  pythia check # fill in connections.json first, then verify
133
133
  ```
134
134
 
@@ -204,7 +204,10 @@ hand with
204
204
  works just as well.
205
205
  `pythia check` warns when the session runs with more power than the task needs.
206
206
 
207
- Using Claude Code? [`examples/claude-code-settings.example.json`](examples/claude-code-settings.example.json)
207
+ Using Claude Code? `pythia install` wires the two hooks — the approve-in-chat
208
+ door and the session-start guide — and the deny rule into
209
+ `.claude/settings.json`, merged into whatever is already there. The rest of
210
+ [`examples/claude-code-settings.example.json`](examples/claude-code-settings.example.json)
208
211
  stops it prompting for the read-only commands and asks it to pause on
209
212
  writes — optional, and yours to install
210
213
  ([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
@@ -237,6 +237,119 @@ def test_cmd_install_runs_end_to_end():
237
237
  assert env is not None
238
238
 
239
239
 
240
+ def test_claude_hook_block_matches_the_example_file():
241
+ """What install wires into .claude/settings.json is exactly the hooks
242
+ block and the deny rule of the example file — the reviewable source of
243
+ truth, as with the connections template. The allow list and autoMode
244
+ stay in the example: those are the developer's permission preferences."""
245
+ ex = json.loads((ROOT / "examples" / "claude-code-settings.example.json")
246
+ .read_text(encoding="utf-8"))
247
+ assert pythia.CLAUDE_HOOKS == ex["hooks"]
248
+ assert pythia.CLAUDE_DENY == ex["permissions"]["deny"]
249
+
250
+
251
+ def test_install_claude_hooks_creates_settings_and_is_idempotent():
252
+ with tempfile.TemporaryDirectory() as td:
253
+ path, added = pythia.install_claude_hooks(td)
254
+ assert path == pathlib.Path(td) / ".claude" / "settings.json"
255
+ assert added == ["hooks.SessionStart", "hooks.PostToolUse",
256
+ "permissions.deny"]
257
+ s = json.loads(path.read_text(encoding="utf-8"))
258
+ assert s["hooks"] == pythia.CLAUDE_HOOKS
259
+ assert s["permissions"]["deny"] == pythia.CLAUDE_DENY
260
+ before = path.read_text(encoding="utf-8")
261
+ assert pythia.install_claude_hooks(td) == (path, [])
262
+ assert path.read_text(encoding="utf-8") == before
263
+
264
+
265
+ def test_install_claude_hooks_merges_into_existing_settings():
266
+ """The file is Claude Code's, not pythia's: every key the developer put
267
+ there survives, their own hooks stay, and a pythia hook already present
268
+ in another spelling (python3, the plugin's path) is recognised, not
269
+ doubled. Only the missing pieces are added."""
270
+ with tempfile.TemporaryDirectory() as td:
271
+ path = pathlib.Path(td) / ".claude" / "settings.json"
272
+ path.parent.mkdir(parents=True)
273
+ path.write_text(json.dumps({
274
+ "model": "opus",
275
+ "permissions": {"allow": ["Bash(git status)"],
276
+ "deny": ["Bash(rm -rf *)"]},
277
+ "hooks": {
278
+ "SessionStart": [{"hooks": [
279
+ {"type": "command", "command": "echo hi"}]}],
280
+ "PostToolUse": [{"matcher": "AskUserQuestion", "hooks": [
281
+ {"type": "command",
282
+ "command": "python3 -m pythia approve --hook"}]}]}}),
283
+ encoding="utf-8")
284
+ _, added = pythia.install_claude_hooks(td)
285
+ assert added == ["hooks.SessionStart", "permissions.deny"]
286
+ s = json.loads(path.read_text(encoding="utf-8"))
287
+ assert s["model"] == "opus"
288
+ assert s["permissions"]["allow"] == ["Bash(git status)"]
289
+ assert s["permissions"]["deny"][0] == "Bash(rm -rf *)"
290
+ assert set(pythia.CLAUDE_DENY) <= set(s["permissions"]["deny"])
291
+ cmds = [h["command"] for ev in s["hooks"].values()
292
+ for e in ev for h in e["hooks"]]
293
+ assert "echo hi" in cmds
294
+ assert sum("approve --hook" in c for c in cmds) == 1 # not doubled
295
+ assert sum("guide --brief" in c for c in cmds) == 1
296
+
297
+
298
+ def test_install_claude_hooks_refuses_a_file_it_cannot_parse():
299
+ """Broken JSON is still the developer's file. Touching it would destroy
300
+ what they wrote; say so and leave it byte-identical."""
301
+ with tempfile.TemporaryDirectory() as td:
302
+ path = pathlib.Path(td) / ".claude" / "settings.json"
303
+ path.parent.mkdir(parents=True)
304
+ path.write_text("{not json", encoding="utf-8")
305
+ assert pythia.install_claude_hooks(td) == (path, None)
306
+ assert path.read_text(encoding="utf-8") == "{not json"
307
+
308
+
309
+ def test_cmd_install_wires_the_claude_hooks_unless_told_not_to():
310
+ """The chat approval door needs the hook; a pip user who never opened
311
+ GUIDE section 11 had none — seen in the 0.10.0 field test. install wires
312
+ it by default and says so; --no-hooks leaves Claude Code's file alone."""
313
+ import argparse
314
+ import contextlib
315
+ import io
316
+ old = os.environ.get("PATH")
317
+ os.environ["PATH"] = "" # no npx: bundled path, offline
318
+ try:
319
+ for no_hooks in (False, True):
320
+ with tempfile.TemporaryDirectory() as td:
321
+ ns = argparse.Namespace(project_root=td, glob=False,
322
+ source=None, color=False, json=False,
323
+ no_hooks=no_hooks)
324
+ buf = io.StringIO()
325
+ with contextlib.redirect_stdout(buf):
326
+ pythia.cmd_install(None, None, ns)
327
+ settings = pathlib.Path(td) / ".claude" / "settings.json"
328
+ if no_hooks:
329
+ assert not settings.exists()
330
+ continue
331
+ s = json.loads(settings.read_text(encoding="utf-8"))
332
+ assert "approve --hook" in json.dumps(s["hooks"]["PostToolUse"])
333
+ assert set(pythia.CLAUDE_DENY) <= set(s["permissions"]["deny"])
334
+ assert "settings.json" in buf.getvalue()
335
+ finally:
336
+ if old is not None:
337
+ os.environ["PATH"] = old
338
+
339
+
340
+ def test_global_install_wires_the_approve_hook_only():
341
+ """~/.claude/settings.json applies to every project on the machine. The
342
+ approve hook is silent unless a question carries a pythia token, so it
343
+ belongs there; the session-start guide would put 15 lines about pythia
344
+ into every session of every project, so that one stays per project."""
345
+ with tempfile.TemporaryDirectory() as td:
346
+ path, added = pythia.install_claude_hooks(td, events=("PostToolUse",))
347
+ assert added == ["hooks.PostToolUse", "permissions.deny"]
348
+ s = json.loads(path.read_text(encoding="utf-8"))
349
+ assert "SessionStart" not in s["hooks"]
350
+ assert s["hooks"]["PostToolUse"] == pythia.CLAUDE_HOOKS["PostToolUse"]
351
+
352
+
240
353
  # Native paths: on POSIX ':' is the PATH separator and would cut a
241
354
  # Windows path in half, so these are built from os.sep.
242
355
  SCRIPTS = os.path.join(os.sep + 'opt', 'a', 'Scripts')
@@ -32,6 +32,11 @@ def test_classify_groups():
32
32
  assert c("alter table t_order add (x number)") == "structural" # session tested first
33
33
  assert c("CREATE OR REPLACE PROCEDURE p AS BEGIN NULL; END;") == "plsql_source"
34
34
  assert c("create or replace editionable package body pkg_order as end;") == "plsql_source"
35
+ # Oracle's keyword is NONEDITIONABLE, with the middle N. Missing it sent
36
+ # every such object to structural — deny by default, and counted by
37
+ # semicolons, so a PL/SQL body came back as "more than one statement".
38
+ assert c("create or replace NONEDITIONABLE procedure p as begin null; end;") == "plsql_source"
39
+ assert c("CREATE OR REPLACE NONEDITIONABLE PACKAGE BODY pkg AS END;") == "plsql_source"
35
40
  assert c("CREATE OR REPLACE FORCE VIEW v AS SELECT 1 FROM dual") == "plsql_source"
36
41
  assert c("CREATE TABLE t (x number)") == "structural" # other CREATE
37
42
  assert c("insert into t values (1)") == "data_dml"
@@ -54,6 +59,27 @@ def test_parse_object():
54
59
  assert (t, n, s) == ("TYPE BODY", "T_THING", None)
55
60
  t, n, s = pythia.parse_object("CREATE OR REPLACE TRIGGER trg_x BEFORE INSERT ON t BEGIN NULL; END;")
56
61
  assert (t, n, s) == ("TRIGGER", "TRG_X", None)
62
+ # the identity drives the snapshot, so the editionable keyword must not
63
+ # shift what is parsed out of the statement
64
+ t, n, s = pythia.parse_object(
65
+ "CREATE OR REPLACE NONEDITIONABLE PROCEDURE core_bh.pbh_file_nh AS BEGIN NULL; END;")
66
+ assert (t, n, s) == ("PROCEDURE", "PBH_FILE_NH", "CORE_BH")
67
+
68
+
69
+ def test_restore_keeps_the_editionable_property():
70
+ """ALL_SOURCE holds no CREATE prefix, so the restore builds one. On an
71
+ editions-enabled schema a bare CREATE OR REPLACE over a NONEDITIONABLE
72
+ object is ORA-38824 — an undo that fails exactly when it is needed."""
73
+ body = "PROCEDURE p AS BEGIN NULL; END;"
74
+ assert pythia.render_restore("PROCEDURE", "P", body, "CREATE OR REPLACE " + body) \
75
+ == "CREATE OR REPLACE " + body + "\n"
76
+ assert pythia.render_restore(
77
+ "PROCEDURE", "P", body,
78
+ "create or replace noneditionable procedure p as begin null; end;") \
79
+ == "CREATE OR REPLACE NONEDITIONABLE " + body + "\n"
80
+ # a new object still has no source to put back: undo is a DROP
81
+ assert pythia.render_restore("PROCEDURE", "P", "", "CREATE OR REPLACE NONEDITIONABLE PROCEDURE p") \
82
+ == "DROP PROCEDURE P\n"
57
83
 
58
84
 
59
85
  def test_prepare_statement_terminators():
@@ -1475,6 +1501,38 @@ def test_apply_reads_a_bom_file_as_the_statement_it_is():
1475
1501
  assert "approve --card" in buf.getvalue() # it previewed
1476
1502
 
1477
1503
 
1504
+ def test_apply_previews_a_noneditionable_object_end_to_end():
1505
+ """The reported bug, from the outside: NONEDITIONABLE missed the
1506
+ classifier, fell through to structural — which counts statements by
1507
+ semicolon — and a PL/SQL body came back as 'more than one statement'.
1508
+ The only escape was dropping the keyword, which Oracle then met with
1509
+ ORA-38824. Preview must produce a token, and the restore must keep the
1510
+ property."""
1511
+ import contextlib
1512
+ import io
1513
+ import pathlib
1514
+ src = ("CREATE OR REPLACE NONEDITIONABLE PACKAGE BODY pkg_order AS\n"
1515
+ " new line;\nEND;\n/\n")
1516
+ with tempfile.TemporaryDirectory() as td:
1517
+ f = pathlib.Path(td) / "f.sql"
1518
+ f.write_text(src, encoding="utf-8")
1519
+ buf = io.StringIO()
1520
+ with contextlib.redirect_stdout(buf):
1521
+ pythia.cmd_apply(FakeConn(base_script()), "APP",
1522
+ apply_ns(td, file=str(f)))
1523
+ out = buf.getvalue()
1524
+ assert "more than one statement" not in out
1525
+ assert "approve --card" in out # it previewed
1526
+ entry = pythia.list_journal_entries(td)[0]
1527
+ restore = (pythia.journal_root(td) / entry / "restore.sql").read_text(
1528
+ encoding="utf-8")
1529
+ assert restore.startswith("CREATE OR REPLACE NONEDITIONABLE ")
1530
+ # and the keyword is not shown as a change it is not: ALL_SOURCE holds
1531
+ # no header, so the diff's base has to carry the same one
1532
+ assert "-CREATE OR REPLACE" not in out and "+CREATE OR REPLACE" not in out
1533
+ assert "2 lines changed" in out # the body line, not the header
1534
+
1535
+
1478
1536
  def test_apply_refuses_when_the_session_schema_is_not_the_configured_one():
1479
1537
  """A proxy into another user, or a stale `schema` in connections.json:
1480
1538
  an unqualified CREATE would land in one schema while pythia snapshots
File without changes
File without changes