Blitz Report Development / Migration
Migrate Custom Reports
migrate-custom-reports
Phase-2 engagement workflow for migrating a customer's gathered custom Oracle EBS reports (the Blitz Report Custom Reports Converter zip output) into Blitz Reports — inventory, extract SQL, classify each report into one of three outcomes (complete map / enhance / new custom build), build/recommend, categorize as "Custom Reports <ABBR>", and prove each report prints what the original prints (field inventory + value fidelity gate) on the conversion instance.
Use
/plugin marketplace add enginatics/ai-skills
/plugin install blitz-report-ai-skills@enginaticsThen ask Claude for the task, or run:
/blitz-report-ai-skills:migrate-custom-reportsBlitz Reports used
- Blitz Report Custom Reports Converter
- FA Depreciation Projection
- GL Account Analysis
- GL Trial Balance - Detail
- MSC Horizontal Plan
Skill contents
SKILL.mdbuild_xml.pyclassify_probe.pyfield_inventory.pyreferences/fidelity-audit-prompt.mdreferences/value-fidelity.mdrex_parse.pyverify_assignments.pyvs_parse.py
SKILL.md
## When to use
A customer has run the **Blitz Report Custom Reports Converter** Blitz Upload on their instance and handed over the gathered zip (`CONV_<ABBR>_<reqid>_<timestamp>/`). This skill takes that zip and turns each custom report into a recommendation or a built Blitz Report, in a customer-specific category.
Read `custom-reports-migration` skill first — it holds the durable taxonomy, the gathered-folder layout, the gotchas, and the category-naming rule this skill depends on. For the per-report RDF/BIP→Blitz mechanics (SQL consolidation, anchors, assignments, parameter binding), delegate to the `convert-bip-and-rdf-reports-to-blitz` skill.
## Arguments
`$ARGUMENTS` should contain:
- The **customer abbreviation** (e.g. `ABC`) — drives the category name `Custom Reports <ABBR>`.
- The **path to the gathered zip OR an unzipped folder** (e.g. `.../CONV_ABC_72146542_20260527_163231.zip` or the extracted folder). A zip is fine — unzip it first (step 0).
- Optionally, a single program name to process just one report.
## Instance
Run against the **conversion instance** (see `blitz-environment.md` for how to reach it, connect as `apps/$APPS_PWD`, copy files to it and query it). Never use any other instance for a conversion.
## Workflow
### 1. Inventory the gathered package
- **Step 0 — if `$ARGUMENTS` points at a `.zip`, unzip it to a working directory first** (e.g. the scratchpad), then operate on the extracted `CONV_<ABBR>_<reqid>_<timestamp>/` folder. A folder path is used as-is. The customer typically sends the **gather zip** plus the **converter result Excel** (which lists the programs they selected) — use the Excel to confirm scope.
- Read `manifest.txt` and `gather_summary.txt` — list each program, its execution method (P=Reports, I=PL/SQL, Q=SQL*Plus, H=Host, K/J=Java), and which artifacts came through.
- Note, per program, whether `<PROG>_rdf.xml` is present (rwconverter succeeded) and whether any `<OWNER>.<NAME>.<type>.sql` custom-dependency files exist.
- **Noetix detection:** a `noetix/_noetix_conversion.txt` marker in an item's folder (program type `Noetix View` or `Excel4apps` in the manifest) means this is a **Noetix conversion** — route it through the Noetix path in step 4 (see `custom-reports-migration` skill → "Noetix conversion"). The `noetix/` subfolder holds the recursive `NOETIX_SYS` view/package closure; `<PROG>_wand.sql` (Excel4apps items) holds the consumer report SQL.
- **Two-step extract→report detection:** a PL/SQL **wrapper/data-extract program** gathered alongside a BIP/report child (manifest shows e.g. `XX_EXTRACT_WRAPPER|Data Extract|...` next to `XX_EXTRACT_REPORT|BI Publisher|...`), both folders sharing the SAME custom staging-table dependency closure, and the wrapper package spec exposing a conc-program `MAIN` entry + a populate procedure + a submit-child procedure — pair them and plan **ONE combined conversion**, not two (see `custom-reports-migration` skill → "Two-step extract → temp table → report processes").
- **🚨 Request the reference outputs NOW, with the scope confirmation:** one output of each original program from the customer's system (Excel, CSV or the XML/PDF it produces), with the parameters it was run with and the run time. It is the arbiter of the value fidelity gate (step 6): printed columns, order and headings, row grain, and every value the file lets you recompute. A report without one can still be built, but its delivery email says that the figures are unverified until the customer compares them. Our own earlier Blitz output is never a reference.
### 2. Per report — gather the facts (do NOT skip the .ldt or .rtf)
- **Parameters & value sets — the AUTHORITATIVE source is `<PROG>.ldt`, NOT the SQL binds.** Build the full parameter inventory per the rule's **🚨 Parameter fidelity** section (Step P1): for each LDT parameter capture `FORM_LEFT_PROMPT` (→ Blitz name), `FLEX_VALUE_SET_NAME` (→ LOV), `REQUIRED_FLAG`, `DISPLAY_FLAG`, `DEFAULT_TYPE`/`DEFAULT_VALUE`, and **`SRW_PARAM`** (→ the RDF bind/token, the authoritative mapping). **Never derive parameters from the rwconverter SQL bind list — that drops PL/SQL-driven parameters and invents wrong ones.**
- **Data-model SQL:**
- If `<PROG>_rdf.xml` present → parse the data-model query from it. Also read its `<programUnits>` / formula columns / triggers — parameters used only there (not in the SELECT binds) are still real parameters (Step P3).
- If absent → rwconverter segfaulted (large/complex RDF). Strings-extract the RDF (`strings -n 8 <PROG>.rdf` or a printable-char split) to **identify** the report and its tables. Do not expect a clean single SQL from a procedural multi-cursor RDF.
- **Output columns:** read the `.rtf` `<?FIELD?>` codes — the columns the customer's layout actually renders — together with the heading printed over or beside each one: the Blitz column must show that heading verbatim (rule "Data-model SQL", headings bullets). (`_ds.ldt` is BIP template registration only — no SQL.)
- **🚨 Field inventory — MANDATORY, before writing any SQL:** `python field_inventory.py <program folder>` writes `<PROG>_field_inventory.md`. It reads the `_rdf.xml` (or the `.rex` when rwconverter failed), the data template, the RTF and the custom package bodies, and lists:
1. every printed field and where the original computes it (query column, formula, placeholder, summary, parameter, or the package variable the loop writes, with the lines that assign it);
2. every formula, with the placeholders it assigns, its `select into` count and its exception handlers;
3. everything that decides which rows print (group filters, parent-child links, format triggers that can return FALSE, layout conditions and loops, and the triggers that build lexicals);
4. each query's `union` / `union all` counts and `order by`.
The query is only part of the report. Most conversion defects sit in the per-row code after it: package loop code filling cursor literals, formula columns, a group filter hiding a section. The inventory is the conversion's work list. Fill its Blitz expression column as you build, and keep the file with the deliverables. A field with no Blitz expression is an open defect, not a simplification.
- **Custom objects:** list the `<OWNER>.<NAME>.<type>.sql` dependency files.
- **Value sets:** the `.ldt` embeds the full `VALUE_SET` definitions. For the Blitz parameters use **`LOV Oracle` + `LOV_NAME=<value-set name>`** (Step P2) — reuse the existing Oracle value set, do NOT re-author LOV SQL. To test on the conversion instance, load the value sets there (standalone value-set LDT) so `LOV Oracle` resolves.
- **🚨 Lists of values — REUSE, in this order: shared Blitz Report LOV → the program's Oracle value set → `LOV custom` LAST (rule Step P2a).** For every list-bearing parameter, query the shared catalogue for the master table it picks from (`xxen_report_parameter_lovs_v` … `dbms_lob.instr(lov_query,'<table>')>0`) and record which shared LOV you considered. The shared LOVs carry **security** you will not reproduce by hand (525 shared LOVs on an R12.1.3 instance: 24 GL-access-set aware, 33 `org_access_view` aware, 13 MOAC aware). **When id-ness forces a custom LOV, re-author only the SELECT LIST and copy the shipped LOV's `where` clause verbatim** — improvising it ships an unrestricted list of every ledger, or *under*-returns by missing ledger-set expansion.
### 3. Classify into one of three outcomes
Apply the evidence-based 3-outcome taxonomy from the rule (`custom-reports-migration` skill → "The 3-outcome classification"):
- **Outcome 1 — complete map:** stock value sets + stock tables + **no** custom-dependency files ⇒ a standard Oracle report an existing Blitz Report already covers. Identify the matching Blitz Report by the standard program/tables.
- **Outcome 2 — partial map:** an existing Blitz Report covers most of it but lacks columns/filters ⇒ propose enhancing it **after user confirmation**; if declined/infeasible, fall through to Outcome 3.
- **Outcome 3 — new custom report:** bespoke SQL / custom objects / customer-specific columns / row-security ⇒ build new.
Classify by evidence, not by program name (customers rename standard programs).
#### 🚨 MANDATORY GATE — run `classify_probe.py` BEFORE writing down any outcome
A column-name comparison is **not** a classification. Column overlap tells you the two reports *display*
similar things; it says nothing about whether the shipped report can *produce* the customer's rows. The only
thing that decides that is the **data sources**. Do not reason about this from memory or from the column list —
**run the probe and paste its output into the engagement notes.**
```bash
# 1. dump the candidate shipped report's SQL from the BASE TABLE (xxen_reports_v truncates at 32764 bytes)
# select xr.sql_text from xxen_reports xr, xxen_reports_v v
# where xr.report_id=v.report_id and v.report_name='<shipped report>'; -> shipped.sql
# 2. run the probe with EVERY customer prefix you have seen, not just manifest.txt's
python ${CLAUDE_PLUGIN_ROOT}/skills/migrate-custom-reports/classify_probe.py \
--custom _extract/<PROG>_datamodel_1.sql --shipped shipped.sql --prefixes XX,YY
```
It compares localization objects (`JAI_*`/`JA_IN_*`), customer-owned objects (by prefix), standard EBS objects
and union-branch counts, then applies a **deterministic verdict** (exit code 3 = Outcome 3):
| signal | verdict |
|---|---|
| a localization object the shipped report does not reference | **Outcome 3** |
| any customer-owned object referenced by the custom SQL | **Outcome 3** (and confirm its DDL was gathered) |
| materially more union branches than the shipped report | **Outcome 3** — investigate |
| none of the above | Outcome 1/2 candidate — *now* do the column diff |
**Do not override the verdict with judgement.** Example: a custom GL detail report called a two-column enhancement of
`GL Account Analysis` from column names alone; the probe on that exact pair reports 3 localization tables,
1 customer package and 9-vs-3 union branches — Outcome 3.
The probe also prints an **UNCLASSIFIED** list (identifiers matching no known prefix). Check those by hand —
an unrecognised customer naming convention is exactly how a customer package goes missing from the gather.
### Build helpers
- `field_inventory.py <program folder> [<PROG>] [--out <file>]` writes the field inventory of step 2. It reads `_rdf.xml`, `.rex` (through `rex_parse.py`), data templates, RTF layouts and custom package bodies.
- `rex_parse.py <folder> <PROG>` decodes a `.rex` text export when rwconverter fails, writing each query's SQL and the PL/SQL units next to it.
- `vs_parse.py` (run inside a program's folder) prints every gathered value set: validation type, table, id and value columns, where clause. Use it to pick the shared LOV that matches each parameter.
- `build_xml.py <spec.py> <out.xml>` writes the import XML. The spec defines `CATEGORY` and `REPORTS`, a list of report dicts (`NAME`, `DESCRIPTION`, `SQL` file, `GUID`, `ASSIGNMENTS` = the converter's `_assignments.xml`, `PARAMS`, optional `DB_PACKAGE`, and `SHORT_NAME` and `PROGRAM` of the program the report replaces, written as the description's last lines `Source:` and `Short Name:`), so one call writes the category deliverable with every report and all assignments; a single report at top level still works. A `PARAMS` row takes name, anchor, sql (omit for a `:bind` anchor), type `LOV`/`LOV Oracle`/`LOV custom`/`Date`/`Char`/`Number`, `lov` or `lov_query`, `required`, `default`, `matching`, `hidden`, and `sub` for a further anchor of the parameter above. Shared LOVs are copied with their fallback payload from the shipped report XMLs (`content/r12/reports` in the Blitz Report repository, or the folder named by the environment variable `BLITZ_CONTENT_DIR`, e.g. `xxen_api.export_report` exports of shipped reports using those LOVs); the build stops when a parameter has other than one display row or an `n=n` anchor is not unique in the SQL. Keep the spec and SQL files next to the category file so a re-issue rebuilds from them. Run this script in place, or set `BLITZ_CONTENT_DIR` for a copy: it finds the shipped reports relative to itself, and a copy stops with `shared LOV not found`.
- A report cloned from a shipped one (an aging, a trial balance) is built by transforming the shipped XML instead: new GUID, name, description (ending with the `Source:` and `Short Name:` lines), category and assignments, extra columns inserted at the level where their keys are, extra parameters appended. Invoice-level extras go into the detail lexical as `max(x1.col)` so summary mode is untouched.
### 4. Build / recommend per outcome
- **Outcome 1:** **recommend the shipped Blitz Report — do not ship a clone by default** (one-version-of-truth; a per-customer copy of a standard report is maintenance debt). To *prove* equivalence, you may build a throwaway clone — `xxen_api.export_report(<report_id>)` → change `GUID`, `REPORT_NAME`, replace `CATEGORY_ASSIGNMENTS` with `Custom Reports <ABBR>` → `xxen_api.import_xml` → confirm identical SQL + parameter count + behavior → then **delete it** with `xxen_api.delete_report(<id>,'Y')`. Only keep a clone if the customer explicitly insists on an isolated/branded copy.
- **🚨 The Outcome-1 DELIVERABLE IS A TEMPLATE, not a report.** "Recommend the shipped report" is not the whole job — the customer's users expect *their* column set, order and layout. Ship one **named template per replaced custom report** (`<ABBR> <original report's user-visible name>`, e.g. `ABC Division Wise Trial Balance`) on the shipped report, so switching is a template pick rather than retraining.
- **Templates import STANDALONE — never round-trip the whole report to add one.** `xxen_api.export_report` returns an **empty** `<TEMPLATES>` section, so re-importing an exported report would DELETE the shipped templates. Use the template-only shape (`xxen_api.export_template(<template_id>)` produces it): a `REPORTS_ROW` carrying **only** `<GUID>` (the report's GUID) + `<TEMPLATES>`, with **no `SQL_TEXT`**. That cannot touch the report definition. (A delta *with* other report elements but no `SQL_TEXT` raises `ORA-01407` — see `report-development` skill.)
- **Before building a pivot template, check what the shipped report already exposes.** Several shipped reports already pivot generically — e.g. `GL Trial Balance - Detail` has a **Pivot Segment** parameter (`&lp_pivot_segment_name`, resolved from `fnd_id_flex_segments_vl`) plus `PIVOT_SEGMENT_VALUE` / `ACCOUNT_SEGMENT` columns and a shipped `Trial Balance Pivot` template. A custom RDF that hand-codes one column per division/unit maps onto it exactly: put the account on `ROW`, the division on `COLUMN`, the balance on `VALUE`. That turns *N* hardcoded columns into data, so a new division needs no code change.
- Pivot template XML: `<TEMPLATE_PIVOT><TEMPLATE_PIVOT_ROW>` with `COLUMN_NAME`, `FIELD_TYPE` (`ROW`/`COLUMN`/`VALUE`/`FILTER`), `DISPLAY_SEQUENCE`, and `AGGREGATION` (`SUM`…) on `VALUE` rows. Every column referenced in `TEMPLATE_PIVOT` must also appear in `TEMPLATE_COLUMNS`. Model on the templates of the shipped `MSC Horizontal Plan` report.
- **Verify the pivot actually materialised**, not just that the request completed: the output `.xlsx` must contain `xl/pivotTables/pivotTable1.xml` and `xl/pivotCache/…` (`unzip -l <file>.xlsx`). A run can finish Normal and still emit flat data if the template was not applied.
- **Outcome 2:** enhance the existing Blitz Report per the standard report-modification workflow (`development-workflow` skill → "Standard Workflow for Modifying an Existing Report") — baseline, change, redeploy, regression-diff. **Plus a named `<ABBR> <original report name>` template** listing the custom report's columns **in the custom report's own order** (`TEMPLATE_COLUMNS.DISPLAY_SEQUENCE` = the RTF/layout field order), so the enhanced shipped report reproduces the customer's familiar layout.
- **Measure the delta before proposing an enhancement.** Diff the custom report's real output columns (the `.rtf` `<?FIELD?>` list, or the RDF formula-column names) against the shipped report's columns across **all** its templates (`xxen_report_template_columns`), not just one. Column names differ freely (`SOURCE`→`SOURCE_NAME`, `TRAN_NO`→`TRANSACTION_NUMBER`, `PARTY_NAME`→`VENDOR_OR_CUSTOMER_NAME`), so build a name-mapping table.
- **🚨 A COLUMN-NAME MATCH IS NOT A CLASSIFICATION. You MUST also compare DATA SOURCES before calling anything Outcome 1 or 2.** Column overlap says the two reports *display* similar things; it says nothing about whether the shipped report can *produce* the customer's rows. Always check, in the shipped report's **`sql_text`**:
- **localization coverage** — count `jai_` / `ja_in` / `india` references. Most shipped reports have **none**;
- **subledger/source coverage** — which sources the branches actually cover vs the custom report's `UNION ALL` branches;
- **customer-owned objects** — any `<prefix>_*` package/view the custom SQL calls, which the shipped report by definition does not.
Example: a custom GL detail report was classified Outcome 2 ("only `PO_NUMBER` + receipt number missing") purely from column names. Checking the SQL showed the shipped `GL Account Analysis` has **0** `jai_` references and 2 union branches, while the custom report has **8** branches (Inventory / Payables / Receivables / **Payables India / Purchasing India / Register India / VAT India**), 4 India Localization tables and **25 calls to a customer package**. It was Outcome 3 all along.
- **🚨 Read `sql_text` from the BASE TABLE `xxen_reports`, not `xxen_reports_v`** when measuring — the view truncates the CLOB at 32,764 bytes, which silently makes two different reports look the same length and hides content past the cut. (`GL Account Analysis` is 61,606 bytes; the view showed 32,764.)
- **Only add columns that are GENERIC.** A column that is standard EBS data (a PO number, a receipt number) is a legitimate shipped-report enhancement. Customer accounting *policy* baked into the report — provision percentages, entity-specific exception lists, a particular `gl_daily_rates` conversion type, hardcoded division codes — must NOT go into the shipped report. Offer the customer the choice explicitly: **(a)** parameterise it so the shipped report stays neutral, or **(b)** build a separate `Custom Reports <ABBR>` report. This is a decision for the customer, not a default — ask. When they choose (b), the custom report's `<DESCRIPTION>` must carry a caveat pointing users to the shipped report as well, so the two are not mistaken for alternatives.
- **Outcome 3:** first create the gathered custom-dependency DDL on the instance (tables may be empty for a parse test), then convert the report via the `convert-bip-and-rdf-reports-to-blitz` skill, category `Custom Reports <ABBR>`. Convert the report, not just its query: the data-model SQL plus every row of the field inventory, with the edge behaviour listed in `references/value-fidelity.md`. That covers what a lookup prints for no row or several, widths, `nvl` defaults, dates as text, row order and grain, serial numbers and report modes. Keep each query's own set operator: `union` where the original has `union`, over the original's key.
- **🚨 Dynamic SQL in the source → lexical anchor, never a frozen copy.** A program that assembles part of its SQL at run time (a select list from a lookup or setup table, a FROM/WHERE fragment chosen by a parameter, a hint, an order by) keeps that behaviour: spec entry `{'name': 'Item Columns', 'anchor': '&item_columns', 'sql': <select returning one line per fragment row>, 'type': 'Char', 'hidden': True, 'default': '<lookup type>'}` with `&item_columns` at the fragment's position in the SQL; for a column list the select returns `<expression> <alias>` per row with a comma on every row but the last, and the FROM must carry the aliases the stored expressions use. Mechanics, limits and the two harness traps are in the rule's "Data-model SQL" bullets (`custom-reports-migration` skill). Writing the gathered day's column list into the SQL is a regression the customer will find the first time they change the lookup.
- **🚨 Design every parameter's anchor per rule Step P3 BEFORE authoring the XML — bind anchors are the exception, never the default.** A WHERE filter (plain equality, LIKE, or a self-referencing `nvl`/`DECODE` no-op) becomes a `1=1` anchor with the predicate in `SQL_TEXT` (Yes/No no-ops become `Yes_No` matching-value rows). A `:bind` anchor is allowed ONLY when the token's every use in the main SQL is SELECT-list/CASE/row-security logic that must stay inline — and such a parameter still needs an id/value LOV instead of raw id entry. Write down the justification per retained bind; you will need it for the step-6 gate.
- **Two-step extract→report pairs (Outcome 3, ONE report for both programs):** convert wrapper + child into a single self-contained Blitz Report using the report-level `<DB_PACKAGE>` before/after-report trigger mechanism (shipped reference: FA Depreciation Projection → `XXEN_FA_FAS_XMLP`). Main SQL = the child's data-model SQL over the staging table keyed to the Blitz request; DB package = the gathered wrapper package adapted (keep populate, drop child-submit and immediate delete, cleanup in `AFTERREPORT`; public variables named after the bind anchors; APPS-owned); **parameter fidelity runs against the WRAPPER's LDT** (it is the user-facing program — the child's REQUEST_ID parameter disappears). Full recipe + engine contract + run-context caveats: `custom-reports-migration` skill → "Two-step extract → temp table → report processes". Only fall back to the child-only conversion (REQUEST_ID parameter, customer keeps their wrapper) when the customer explicitly wants to keep the extract program.
- **Noetix items (always Outcome 3 — never install the NOETIX_SYS views):** start from the consumer SQL (`<PROG>_wand.sql`, or the root view named in `noetix/_noetix_conversion.txt`), inline the `noetix/` view stack bottom-up into nested subqueries, and apply the **transform catalogue** in `custom-reports-migration` skill → "Noetix conversion" (`#` wrappers → base tables, ACL map views → `hr_operating_units`/`gl_ledgers` + MOAC, `xxk_gl_acct` → `gcck` + `&segment_columns`, `noetix_env_pkg.get_language` → `userenv('lang')`, drop `A$*`/copyright columns, …). **Parameters are designed, not converted** — Noetix views have none; pick from the standard Blitz parameter/LOV library per the rule's "Phase-2 parameters" section, mirroring the nearest shipped Blitz report of the same domain. Description must state *"Migrated from NoetixViews view NOETIX_SYS.<VIEW>"*.
- **🚨 Assignments (all built/cloned reports) — CONSUME the gathered file, NORMALISE the level code, then GATE it offline.** Without `<ASSIGNMENTS>` no responsibility can see the report. Three rules, each of which has produced a real defect:
1. **Consume `<PROG>_assignments.xml` verbatim — never hand-list groups.** It is the authoritative set. Carry **every** row and keep each row's **own** `<APPLICATION_SHORT_NAME>` (they are not all the customer's custom app — expect `JA`, `SQLAP`, `AR`, `JE`, `SQLGL` alongside it). Add `<REPORT_NAME>` per row and place the block as a **sibling of `<REPORTS>`** (nesting it inside `REPORTS_ROW` silently drops everything — `report-development` skill "Report Assignments").
2. **Normalise `<ASSIGNMENT_LEVEL>` to what the TARGET instance's build expects.** Packages gathered before the converter fix carry the obsolete single letter, and an unrecognised code is **silently skipped on import** (zero rows, no error). Map through the legacy table in `custom-reports-migration` skill — `S`→Site, `A`→Application, `G`→Request Group, `R`→Responsibility, `U`→User, `F`→**Form**, `D`→Report; `Function` has no legacy letter. Builds ≥21-Jun-2026 need the full words.
3. **Gate it OFFLINE against the gathered file — an instance count of 0 is UNVERIFIABLE, not passed.** On a conversion instance the customer's request groups don't exist, so the import legitimately reports zero and proves nothing. Diff the built XML's rows against the gathered file (count, level value, application short name, level code) with no DB at all. *This is what let defect 1 ship: the zero was explained away instead of checked.*
- **Description (all built/cloned reports):** set `<DESCRIPTION>` (inside `REPORT_TRANSLATIONS_ROW`) to record **the source Oracle report** (`<PROG>` + its user-visible name) and a **brief, plain-language summary** of what the report does (entity, key columns, main filters). Break it into short paragraphs (per `report-development` skill). See the rule's "Description — MANDATORY" section. (A pure Outcome-1 *recommendation* builds no report, so nothing to describe.)
### 5. Category — always `Custom Reports <ABBR>`
Every produced report (clone or new) gets exactly this category. No exceptions.
### 6. Import + validate — the verification gate (rule Step P4)
- Import via `import_xml` (which derives parameter and upload dependencies from any `$flex$`/`$column$` references automatically).
- **🚨 Re-issuing a new version of an already-imported report → DELETE then import fresh** (`xxen_api.delete_report(xxen_report.report_id('<name>'),'Y'); commit;` then `import_xml`). Importing on top upsert-merges by `SORT_ORDER`; if the SORT_ORDER→parameter mapping changed between versions the merge is partial (classic symptom: a `1=1`/`&lexical` anchor parameter whose `SQL_TEXT` came out **blank** — impossible from a clean load). See `report-development` skill "Re-issuing a new VERSION".
- **Post-import: assert no anchor parameter lost its SQL_TEXT** — `select parameter_name,anchor from xxen_report_parameters_v where report_id=xxen_report.report_id('<name>') and (anchor like '%=%' or anchor like '&%') and nvl(dbms_lob.getlength(sql_text),0)=0;` must return **zero rows**.
- **Post-import: assert assignments landed** — `select assignment_level,include_exclude,level_value from xxen_report_assignments_v where report_id=xxen_report.report_id('<name>');` must return the expected rows (zero usually means `<ASSIGNMENTS>` was nested inside `REPORTS_ROW` instead of top-level, or `REPORT_NAME` was missing — see `report-development` skill "Report Assignments").
- **Parse-validate** on the instance.
- **🚨 A wide-open parse+run is NOT acceptance.** Before declaring done, produce the **parameter-by-parameter fidelity table** (rule Step P4) and confirm EVERY row passes: count = LDT count, names = `FORM_LEFT_PROMPT`, every value-set parameter has `LOV Oracle` + correct `LOV_NAME` **and the LOV returns rows**, required flags match, hidden/defaults match, every parameter is wired (bind/SQL_TEXT/lexical) so it actually filters.
- **🚨 Bind-anchor gate (mechanical, run the query — never eyeball):** `select xrpv.parameter_name, xrpv.anchor from xxen_report_parameters_v xrpv where xrpv.report_id=xxen_report.report_id('<name>') and xrpv.anchor like ':%';` — for every returned row, quote the P3-exception justification recorded in step 4 (bind used ONLY in SELECT-list/CASE/row-security logic). Automatic FAIL and re-do: the bind token appears in a WHERE filter predicate, or a retained numeric-id bind parameter has no id/value LOV.
- **🚨 Shared-LOV reuse gate (mechanical, run the query):** `select xrpv.parameter_name, dbms_lob.substr(xrpv.lov_query,300,1) from xxen_report_parameters_v xrpv where xrpv.report_id=xxen_report.report_id('<name>') and xrpv.lov_query is not null;` — this returns **only hand-written LOVs** (a `LOV Oracle` value set and a shared-LOV reference both store `lov_query` null). For each row, state the shared Blitz LOV you considered and why it was rejected — id-ness or genuine non-portability are the only valid reasons. **Automatic FAIL:** the LOV selects from a security-scoped master table (`gl_ledgers`, `hr_operating_units`, `hr_all_organization_units`, `mtl_parameters`, …) with no security predicate, or with one improvised rather than copied from the shipped LOV.
- **E2E with REAL filter values** — through the Blitz **frontend run screen** (it evaluates hidden defaults), or with real filter values set. Confirm the filters actually restrict the output. Never accept a wide-open/default-only run as proof.
- **API hidden-default limitation** (rule → "E2E via the bare API…"): `ORA-01741` / `ORA-01861` under the bare `set_parameter_value_` API are harness limitations, not report defects. For an **Outcome-1 clone**, equivalence to the shipped report (identical SQL + parameter count + behavior) is sufficient.
- **🚨 Value fidelity gate — the acceptance test, for the first delivery AND every correction** (full procedure and harness skeletons: `references/value-fidelity.md`). Every other gate in this step proves the report is well-formed, not that it prints what the original prints. A report that only parses, runs and returns rows is not delivered. All six must pass:
1. **Inventory complete:** every row of the field inventory has a Blitz expression or implementation, or a reason from the source why it never reaches the output.
2. **Formula port:** the original's formulas and per-row package code, ported as static PL/SQL, run on every row of the Blitz SQL, with 0 differences on every column. Where the instance lacks the data (India Localization lines, the customer's own tables), feed identical stand-in rows to both sides.
3. **Reference output:** the customer's ERP output matches on columns, order and headings, every recomputable relation holds on every row, and the row grain and order match.
4. **Old against new** for a correction: 0 differences on every column the change does not intend to alter.
5. **Full-fetch cost:** old and new, every column, over the same window. `count(*)` prunes columns and proves nothing about their cost.
6. **Independent audit** before the first delivery: a second agent with only the original's files and the Blitz XML (`references/fidelity-audit-prompt.md`). Every DIFFERENT or MISSING is fixed or answered from the source before the file goes out.
Record each result in the inventory's Proof column.
- **A report with a `&lexical` in its select list needs the form's parse sequence and an explicit hidden value in the E2E.** Gate it with `xxen_report.initialize_run_param_vals_tmp(id)` → `initialize_parameter_values(id,'Y')` → `parse_report_sql_columns(id)` (a bare parse strips the unresolved anchor and reports `Error parsing SQL.`), run the lexical's own select standalone and count its lines against the source, and pass the hidden parameter in the scripted run (`xxen_api.create_run_id_` applies no defaults; without it the request fails with `ORA-00936`).
- **Heading gate:** after import, render every parsed column through `xxen_report.column_translation(xrsc.column_name)` in the parse session and compare with the headings the original printed, column by column; any difference other than an upper-case identifier heading (rule "Heading limits") is a defect.
- **Open every custom list the way the run form does.** For each `lov_query` of the category run `select x.value, x.description from (<query>) x where rownum<3` on the instance (substitute the `:$flex$` binds); a list that only returns `value` passes a `count(*)` gate and fails on every run screen with `ORA-00904: "X"."DESCRIPTION"` (rule "Mechanical rules for generating the parameter XML").
- **Open every run screen the way the form and the add-in do.** Per report print `initialize_parameter_values`' message on its own line (a parse gate that reuses the variable hides a default the engine rejects), then call `xxen_webservices.report_metadata` and `report_param_lov` for the independent lists with the metadata defaults as binds: a literal default on a Number parameter is a form error and a failed add-in run (rule, same section).
### UAT rounds — answer from the source, never patch the symptom
- **A difference the customer reports goes back to that field's inventory row.** Re-read the original's code for it, find why the Blitz expression differs, and fix the expression to the original's logic. A fix that makes the reported cell right without citing the source line is a guess, and a wrong guess creates the next round. Never choose a date basis, a rate or a set operator by analogy with another report.
- **Check the same pattern on the whole report, then on the category.** A defect class found in one field (per-row code not carried, a handler's blank, a literal the loop fills) is searched for in every other field of that report and every other converted report before the round goes out. The field inventories make that a lookup.
- **Every round re-runs the value fidelity gate for each report it touches.** That means the whole report, not only the changed column, including the full-fetch cost.
- **Record what the round taught in the skill.** A defect class the gate did not catch becomes a check in `references/value-fidelity.md` or a row type in `field_inventory.py`, not a note in the engagement folder.
### 7. Deliverable per report
A short record: classification + evidence, the recommendation or built report (name + category + report_id), the validation result (request_id / row count / equivalence note), and the completed field inventory with the value fidelity results.
The XML deliverable is NOT per report: all reports of the category ship in the single category file (see Output). Import and validate that file as a whole (delete the category's reports on the instance, import the category file, re-run the gates) before handing it over — the per-report intermediates are not what the customer receives.
## Output
- **ONE report definition XML per category** — `reports_Custom_Reports_<ABBR>_with_assignments_vNN.xml` in `blitz_converted/` next to the gathered folder, holding EVERY report of `Custom Reports <ABBR>` (all built/cloned reports of the engagement, not just this batch) plus the `<ASSIGNMENTS>` of all of them. Never deliver or keep one XML per report: the customer imports and re-imports a single file, and separate files drift apart (decision: product owner). A re-issue is the next `_vNN` of the same category file. Per-report XMLs are at most an internal build intermediate and are moved to `_superseded/` once the category file exists.build_xml.py
Show build_xml.py
"""Build a Blitz Report import XML: python build_xml.py <spec.py> <out.xml>
spec.py defines CATEGORY and either REPORTS (a list of report dicts - the category deliverable, every
report plus all their assignments in one file) or the keys of a single report at top level.
A report dict: NAME, DESCRIPTION, SQL (path), GUID, ASSIGNMENTS (the converter's _assignments.xml), PARAMS,
SHORT_NAME (the short name of the program the report replaces, the view name for a custom view) and optional PROGRAM
(its user-visible name), optional NUMBER_FORMAT (default #,##0.00;[Red]-#,##0.00; 'General' prints numbers as the original prints them),
optional DB_PACKAGE (the APPS package holding beforereport/afterreport),
optional VALIDATE_FROM_LIST=True for a target build dated before 18-Oct-2026 (Build Date on the converter workbook's Parameters
sheet), which validates a list only when the file says so and is ignored by later builds,
optional TEMPLATES and DEFAULT_TEMPLATE (the name of the template every user gets without choosing one).
PARAMS lists one dict per PARAMETERS_ROW, in order:
name, anchor, sql sql is the SQL_TEXT; omit it for a :bind anchor
type Date, Char, Number, LOV (shared Blitz LOV), LOV Oracle (value set), LOV custom
lov, lov_query LOV / LOV Oracle name, LOV custom query
fbd LOV custom only: filter before display (a list too long to download, e.g. all suppliers)
required, default REQUIRED=Y, DEFAULT_VALUE
description the run screen tooltip (US translation row)
matching MATCHING_VALUE
hidden negative DISPLAY_SEQUENCE
display_sequence explicit DISPLAY_SEQUENCE, to show a parameter elsewhere on the run screen without moving its
SORT_ORDER (the import's upsert key)
sub a further anchor of the same parameter: no display sequence, type, LOV, default or required
lexical anchor '&name' placed where the fragment goes; sql is a select returning one line per fragment
row (the engine substitutes all rows); hidden + a constant default so the value is always set
Shared-LOV fallback payloads (LOV_GUID, FILTER_BEFORE_DISPLAY_DSP, LOV_QUERY_DSP) are copied from the shipped content
(the repository's content/r12/reports, or the folder of report XMLs named by the BLITZ_CONTENT_DIR environment variable).
"""
import collections
import glob
import html
import os
import re
import runpy
import sys
REPO = os.path.join(os.environ.get("BLITZ_CONTENT_DIR") or os.path.join(os.path.dirname(__file__), "..", "..", "..", "content", "r12", "reports"), "")
SQL_FUNCTIONS = {"abs", "add_months", "bitand", "cast", "ceil", "chr", "coalesce", "decode", "greatest", "initcap", "instr", "last_day", "least", "length",
"lower", "lpad", "mod", "months_between", "next_day", "nvl", "nvl2", "power", "regexp_replace", "regexp_substr", "replace", "round", "rtrim",
"sign", "substr", "substrb", "to_char", "to_date", "to_number", "translate", "trim", "trunc", "upper", "userenv"}
def shared_lov(name):
for p in glob.glob(REPO + "*.xml"):
s = open(p, encoding="utf-8").read()
m = re.search(r"<LOV_NAME>" + re.escape(name) + r"</LOV_NAME>\s*<LOV_GUID>(\w+)</LOV_GUID>\s*(?:<FREE_TEXT>\w</FREE_TEXT>\s*)?(<FILTER_BEFORE_DISPLAY_DSP>\w</FILTER_BEFORE_DISPLAY_DSP>\s*)?<LOV_QUERY_DSP>(.*?)</LOV_QUERY_DSP>", s, re.S)
if m:
return m.group(1), (m.group(2) or "").strip(), m.group(3)
raise SystemExit("shared LOV not found: " + name)
def e(t):
return html.escape(t, quote=False).replace("'", "'")
def param_rows(rep):
rows, visible = [], 0
for i, p in enumerate(rep["PARAMS"], 1):
r = [f"<SORT_ORDER>{i}</SORT_ORDER>"]
if not p.get("sub"):
visible += 1
r.append(f"<DISPLAY_SEQUENCE>{p.get('display_sequence') or (-visible * 10 if p.get('hidden') else visible * 10)}</DISPLAY_SEQUENCE>")
r.append(f"<ANCHOR>{e(p['anchor'])}</ANCHOR>")
if p.get("sql"):
r.append(f"<SQL_TEXT>{e(p['sql'])}</SQL_TEXT>")
if not p.get("sub"):
r.append(f"<PARAMETER_TYPE_DSP>{p['type']}</PARAMETER_TYPE_DSP>")
validate = ["<VALIDATE_FROM_LIST_DSP>Y</VALIDATE_FROM_LIST_DSP>"] if rep.get("VALIDATE_FROM_LIST") else []
if p["type"] == "LOV":
guid, fbd, q = shared_lov(p["lov"])
r += [f"<LOV_NAME>{e(p['lov'])}</LOV_NAME>", f"<LOV_GUID>{guid}</LOV_GUID>"] + validate + ([fbd] if fbd else []) + [f"<LOV_QUERY_DSP>{q}</LOV_QUERY_DSP>"]
elif p["type"] == "LOV Oracle":
r += [f"<LOV_NAME>{e(p['lov'])}</LOV_NAME>"] + validate
elif p["type"] == "LOV custom":
r += validate + (["<FILTER_BEFORE_DISPLAY_DSP>Y</FILTER_BEFORE_DISPLAY_DSP>"] if p.get("fbd") else []) + [f"<LOV_QUERY_DSP>{e(p['lov_query'].strip())}</LOV_QUERY_DSP>"]
if p.get("matching"):
r.append(f"<MATCHING_VALUE>{e(p['matching'])}</MATCHING_VALUE>")
if not p.get("sub"):
if p.get("default"):
r.append(f"<DEFAULT_VALUE>{e(p['default'])}</DEFAULT_VALUE>")
if p.get("required"):
r.append("<REQUIRED>Y</REQUIRED>")
description = f"\n <DESCRIPTION>{e(p['description'])}</DESCRIPTION>" if p.get("description") and not p.get("sub") else ""
r.append(f"<PARAMETER_TRANSLATIONS>\n <PARAMETER_TRANSLATIONS_ROW>\n <LANGUAGE>US</LANGUAGE>\n <PARAMETER_NAME>{e(p['name'])}</PARAMETER_NAME>{description}\n </PARAMETER_TRANSLATIONS_ROW>\n </PARAMETER_TRANSLATIONS>")
rows.append(" <PARAMETERS_ROW>\n " + "\n ".join(r) + "\n </PARAMETERS_ROW>")
return rows
def select_aliases(sql):
"""output column names: the trailing identifier of every depth-0 item of the top-level select list"""
items, cur, depth, started = [], [], 0, False
for t in re.findall(r"'(?:[^']|'')*'|\"[^\"]*\"|--[^\n]*|/\*.*?\*/|[(),]|[\w$#&:.]+|\S", sql, re.S):
low = t.lower()
if low.startswith(("--", "/*")):
continue
if not started:
started = low == "select"
continue
if t == "(":
depth += 1
elif t == ")":
depth -= 1
elif depth == 0 and t == ",":
items.append(cur)
cur = []
continue
elif depth == 0 and low == "from":
break
cur.append(t)
items.append(cur)
return [i[-1].rsplit(".", 1)[-1].strip('"').lower() for i in items if i]
def check(rep, sql):
# an alias is the column heading: RDF formula, placeholder and summary names must be renamed after the layout heading
rdf = [a for a in select_aliases(sql) if re.match(r"c[fps]_", a)]
if rdf:
raise SystemExit(f"{rep['NAME']}: RDF column names in the select list, name them after the layout heading: {rdf}")
display = collections.Counter(p["name"] for p in rep["PARAMS"] if not p.get("sub"))
for name in {p["name"] for p in rep["PARAMS"]}:
if display[name] != 1:
raise SystemExit(f"{rep['NAME']}: parameter {name} has {display[name]} display rows")
for a in {p["anchor"] for p in rep["PARAMS"]}:
# the engine replaces an n=n anchor as plain text, so x.oh_row1=1 holds the anchor 1=1 too
n = len(re.findall(re.escape(a) if re.fullmatch(r"(\d+)=\1", a) else r"(?<![\w&:])" + re.escape(a) + r"(?!\w)", sql, re.I))
# the framework injects an n=n parameter at EVERY occurrence of its anchor; a report that wants the
# filters inside each union branch declares the intended count as REPEATED_ANCHOR (anchor -> count)
expected = rep.get("REPEATED_ANCHOR", {}).get(a, 1)
if n == 0 or re.fullmatch(r"(\d+)=\1", a) and n != expected:
raise SystemExit(f"{rep['NAME']}: anchor {a} occurs {n} times in the SQL, expected {expected}")
# the run form wraps a custom list as select null id, x.value, x.description from (<query>) x
for p in rep["PARAMS"]:
q = p.get("lov_query")
if q and not (re.search(r"\bvalue\b", q, re.I) and re.search(r"\bdescription\b", q, re.I)):
raise SystemExit(f"{rep['NAME']}: LOV custom of {p['name']} must return value and description columns")
# the import stores filter before display only for a custom list: a value set's comes from its Long List flag, a shared list's from the list
for p in rep["PARAMS"]:
if p.get("fbd") and p.get("type") != "LOV custom":
raise SystemExit(f"{rep['NAME']}: fbd on {p['name']} needs type LOV custom, the import ignores it for {p.get('type')}")
# xxen_report.is_function: a default that is neither a select nor one of these expressions is stored as a literal
for p in rep["PARAMS"]:
d = (p.get("default") or "").strip()
c = re.sub(r"\s", "", d).lower()
f = re.match(r"(\w+)\(", c)
evaluated = re.match(r"(select|with)\b", d, re.I) or re.match(r"(xxen_report\w*|xxen_schub|xxen_util|fnd_global|fnd_profile|:\$flex\$)\.|sys_context\(", c) \
or f and f.group(1) in SQL_FUNCTIONS or c.startswith("sysdate") and p.get("type", "").startswith("Date") or re.fullmatch(r"case\s.*\send", d, re.I | re.S)
if d and not evaluated and (re.fullmatch(r"[a-z_][\w$#]*\.[a-z_][\w$#]*(\(.*\))?", c) or p.get("type") == "Number" and not re.fullmatch(r"-?\d+(\.\d+)?", d)):
raise SystemExit(f"{rep['NAME']}: default of {p['name']} would be stored as the literal text '{d}', write it as select ... from dual")
def template_rows(rep):
"""TEMPLATES_ROW per entry of the report's TEMPLATES list: NAME, GUID, COLUMNS (ordered column names),
PIVOT (list of (column, ROW|COLUMN|VALUE|FILTER[, aggregation])), optional DYNAMIC_COLUMNS (default N),
PIVOT_LAYOUT (T = tabular, no subtotals) and REPEAT_LABELS (Y = every row shows all its row field values),
SHARED (default True: a Site sharing row, so every user of the report can pick it), DISTINCT (Y = one row per distinct
combination of the template's columns) and EXCLUDED_PARAMETERS (parameters hidden and not applied when the template is chosen).
A layout on a NEW report ships inside its definition; the standalone template file is only for a shipped report."""
out = []
for t in rep.get("TEMPLATES", []):
cols = "\n".join(f" <TEMPLATE_COLUMNS_ROW>\n <DISPLAY_SEQUENCE>{i}</DISPLAY_SEQUENCE>\n <COLUMN_NAME>{e(c)}</COLUMN_NAME>\n </TEMPLATE_COLUMNS_ROW>"
for i, c in enumerate(t["COLUMNS"], 1))
seq = collections.Counter()
piv = []
for row in t.get("PIVOT", []):
col, ftype = row[0], row[1]
seq[ftype] += 1
agg = f"\n <AGGREGATION>{row[2]}</AGGREGATION>" if len(row) > 2 else ""
piv.append(f" <TEMPLATE_PIVOT_ROW>\n <COLUMN_NAME>{e(col)}</COLUMN_NAME>\n <FIELD_TYPE>{ftype}</FIELD_TYPE>\n <DISPLAY_SEQUENCE>{seq[ftype]}</DISPLAY_SEQUENCE>{agg}\n </TEMPLATE_PIVOT_ROW>")
missing = {r[0] for r in t.get("PIVOT", [])} - set(t["COLUMNS"])
if missing:
raise SystemExit(f"{rep['NAME']}/{t['NAME']}: pivot columns not in TEMPLATE_COLUMNS: {sorted(missing)}")
pivot = f"\n <TEMPLATE_PIVOT>\n{chr(10).join(piv)}\n </TEMPLATE_PIVOT>" if piv else ""
pivot += "".join(f"\n <{k}>{t[k]}</{k}>" for k in ("PIVOT_LAYOUT", "REPEAT_LABELS") if t.get(k))
unknown = set(t.get("EXCLUDED_PARAMETERS", [])) - {p["name"] for p in rep["PARAMS"]}
if unknown:
raise SystemExit(f"{rep['NAME']}/{t['NAME']}: excluded parameters not in PARAMS: {sorted(unknown)}")
if t.get("EXCLUDED_PARAMETERS"):
pivot += "\n <PARAMETER_EXCLUSION>" + "".join(f"\n <PARAMETER_EXCLUSION_ROW>\n <PARAMETER_NAME>{e(n)}</PARAMETER_NAME>\n </PARAMETER_EXCLUSION_ROW>"
for n in t["EXCLUDED_PARAMETERS"]) + "\n </PARAMETER_EXCLUSION>"
# without a sharing row only the owner can pick the template
sharing = ("\n <TEMPLATE_SHARING>\n <TEMPLATE_SHARING_ROW>\n <SHARING_LEVEL>S</SHARING_LEVEL>\n"
" <LEVEL_VALUE>Site</LEVEL_VALUE>\n </TEMPLATE_SHARING_ROW>\n </TEMPLATE_SHARING>") if t.get("SHARED", True) else ""
out.append(f""" <TEMPLATES_ROW>
<GUID>{t['GUID']}</GUID>
<TEMPLATE_NAME>{e(t['NAME'])}</TEMPLATE_NAME>{chr(10) + ' <DISTINCT_FLAG>Y</DISTINCT_FLAG>' if t.get('DISTINCT') == 'Y' else ''}
<DYNAMIC_COLUMNS>{t.get('DYNAMIC_COLUMNS', 'N')}</DYNAMIC_COLUMNS>
<OWNER>SYSADMIN</OWNER>
<TEMPLATE_COLUMNS>
{cols}
</TEMPLATE_COLUMNS>{pivot}{sharing}
</TEMPLATES_ROW>""")
return out
def description(rep):
# the program link in the format of Blitz Report's own concurrent program import; the migration tracker reads it
if not rep.get("SHORT_NAME"):
raise SystemExit(f"{rep['NAME']}: SHORT_NAME, the short name of the program the report replaces, is required")
source = f"Source: {rep['PROGRAM']}\n" if rep.get("PROGRAM") else ""
return f"{rep['DESCRIPTION'].rstrip()}\n\n{source}Short Name: {rep['SHORT_NAME']}"
def report_xml(rep, category):
sql = open(rep["SQL"], encoding="utf-8").read().replace("\r\n", "\n").strip()
check(rep, sql)
anchors = sorted({p["anchor"] for p in rep["PARAMS"]})
rows = param_rows(rep)
templates = template_rows(rep)
# a DEFAULT_TEMPLATES_ROW without USER_NAME is the global default; it applies only to users who can use the template
default = ""
if rep.get("DEFAULT_TEMPLATE"):
t = next((t for t in rep.get("TEMPLATES", []) if t["NAME"] == rep["DEFAULT_TEMPLATE"]), None)
if not t or not t.get("SHARED", True):
raise SystemExit(f"{rep['NAME']}: default template {rep['DEFAULT_TEMPLATE']} must be one of its TEMPLATES and shared")
default = f"\n <DEFAULT_TEMPLATES_ROW>\n <TEMPLATE_GUID>{t['GUID']}</TEMPLATE_GUID>\n </DEFAULT_TEMPLATES_ROW>"
return f"""<!-- loader xml for Blitz Report: {e(rep['NAME'])} -->
<REPORTS_ROW>
<GUID>{rep['GUID']}</GUID>
<SQL_TEXT>{e(sql)}</SQL_TEXT>
<NUMBER_FORMAT>{e(rep.get('NUMBER_FORMAT', '#,##0.00;[Red]-#,##0.00'))}</NUMBER_FORMAT>{chr(10) + ' <DB_PACKAGE>' + rep['DB_PACKAGE'] + '</DB_PACKAGE>' if rep.get('DB_PACKAGE') else ''}
<REPORT_TRANSLATIONS>
<REPORT_TRANSLATIONS_ROW>
<LANGUAGE>US</LANGUAGE>
<REPORT_NAME>{e(rep['NAME'])}</REPORT_NAME>
<DESCRIPTION>{e(description(rep))}</DESCRIPTION>
</REPORT_TRANSLATIONS_ROW>
</REPORT_TRANSLATIONS>
<CATEGORY_ASSIGNMENTS>
<CATEGORY_ASSIGNMENTS_ROW>
<CATEGORY>{e(category)}</CATEGORY>
</CATEGORY_ASSIGNMENTS_ROW>
</CATEGORY_ASSIGNMENTS>
<ANCHORS>
{chr(10).join(f" <ANCHORS_ROW>{chr(10)} <ANCHOR>{e(a)}</ANCHOR>{chr(10)} </ANCHORS_ROW>" for a in anchors)}
</ANCHORS>
<PARAMETERS>
{chr(10).join(rows)}
</PARAMETERS>
<TEMPLATES>
{chr(10).join(templates)}
</TEMPLATES>
<DEFAULT_TEMPLATES>{default}
</DEFAULT_TEMPLATES>
<UPLOAD_COLUMNS>
</UPLOAD_COLUMNS>
<UPLOAD_PARAMETERS>
</UPLOAD_PARAMETERS>
<UPLOAD_SQLS>
</UPLOAD_SQLS>
</REPORTS_ROW>""", len(rows)
def assignment_rows(rep):
asg = open(rep["ASSIGNMENTS"], encoding="utf-8").read()
return [r.replace("<ASSIGNMENTS_ROW>", f"<ASSIGNMENTS_ROW><REPORT_NAME>{e(rep['NAME'])}</REPORT_NAME>")
for r in re.findall(r"<ASSIGNMENTS_ROW>.*?</ASSIGNMENTS_ROW>", asg, re.S)]
spec = runpy.run_path(sys.argv[1])
category = spec["CATEGORY"]
reports = spec.get("REPORTS") or [spec]
blocks, asg = [], []
for rep in reports:
block, n = report_xml(rep, category)
blocks.append(block)
asg += assignment_rows(rep)
print(f" {rep['NAME']}: {n} parameter rows, {len(assignment_rows(rep))} assignments")
out = f"""<ROOT>
<APPS_INITIALIZE_DATA>
<USER_NAME>ENGINATICS</USER_NAME>
<RESPONSIBILITY_KEY>SYSTEM_ADMINISTRATOR</RESPONSIBILITY_KEY>
<APPLICATION_SHORT_NAME>SYSADMIN</APPLICATION_SHORT_NAME>
</APPS_INITIALIZE_DATA>
<CATEGORIES>
<CATEGORIES_ROW>
<BASE_CATEGORY>{e(category)}</BASE_CATEGORY>
<CATEGORY_TRANSLATIONS>
<CATEGORY_TRANSLATIONS_ROW>
<LANGUAGE>US</LANGUAGE>
<CATEGORY>{e(category)}</CATEGORY>
</CATEGORY_TRANSLATIONS_ROW>
</CATEGORY_TRANSLATIONS>
</CATEGORIES_ROW>
</CATEGORIES>
<REPORTS>
{chr(10).join(blocks)}
</REPORTS>
<ASSIGNMENTS>
{chr(10).join(' ' + r for r in asg)}
</ASSIGNMENTS>
</ROOT>
"""
open(sys.argv[2], "w", encoding="utf-8", newline="\r\n").write(out)
print("wrote", sys.argv[2], len(out), "bytes,", len(reports), "reports,", len(asg), "assignments")classify_probe.py
Show classify_probe.py
#!/usr/bin/env python
"""Classification probe for the custom-reports migration (Phase 2).
Produces the evidence needed to classify a gathered custom report as Outcome 1 / 2 / 3.
Run this BEFORE deciding the outcome and paste its output into the engagement notes.
A column-name comparison is NOT a classification - this probe compares DATA SOURCES,
which is what actually decides whether a shipped Blitz Report can produce the rows.
Example: a custom GL detail report looked like a two-column enhancement of the shipped
"GL Account Analysis" from column-name overlap alone. The shipped report has ZERO India
Localization references and 2 union branches; the custom report had 8 branches, 4 JAI tables
and 25 calls to a customer package. It was Outcome 3.
USAGE
# 1. dump the candidate shipped report's SQL from the conversion instance FIRST:
# (base table, NOT xxen_reports_v - the view truncates the CLOB at 32764 bytes)
# select xr.sql_text from xxen_reports xr, xxen_reports_v v
# where xr.report_id=v.report_id and v.report_name='<shipped report>';
# save it as shipped.sql
#
# 2. python classify_probe.py --custom _extract/<PROG>_datamodel_1.sql \
# --shipped shipped.sql \
# --prefixes XX,YY
#
# --prefixes: ALL customer object prefixes, comma separated. Do not assume the one in
# manifest.txt is complete - the gather itself only scans for that one, which is how
# dependencies get missed (see `custom-reports-migration` skill "Gathering - known gaps").
VERDICT RULE (deterministic - do not override with judgement):
* any localization object (JAI_/JA_IN_) present in custom but absent in shipped -> OUTCOME 3
* any customer-owned object (matching a prefix) referenced by the custom SQL -> OUTCOME 3
* custom has materially more union branches than shipped -> OUTCOME 3 (investigate)
* otherwise -> Outcome 1/2 candidate,
continue with the column diff
"""
import argparse
import os
import re
import sys
ORACLE_STD = re.compile(
r'^(ap|ar|gl|po|inv|mtl|wip|bom|fa|pa|pn|ce|xla|hr|hz|per|fnd|ra|rcv|oe|ont|wsh|qp|cst|msc|'
r'jtf|okc|iby|ego|eam|gmd|gme|gmf|gmi|gmp|opm|cs|csi|cse|zx|xle|xdo|ak|alr|icx|jg|qa|bne|'
r'ben|pay|ota|pqh|pqp|ssp|pjm|pji|pjc|pn|igi|igc|igs|dual)_',
re.I)
FROM_RE = re.compile(
r'(?:\bfrom\b|\bjoin\b|,)\s*(?:([a-z][a-z0-9_$#]{0,60})\s*\.\s*)?([a-z][a-z0-9_$#]{2,60})\s*(?=[\s,);]|$)', re.I) # ';' - a table may be the last token of the statement
PKG_RE = re.compile(r'(?<![.\w])([a-z][a-z0-9_$#]{2,60})\s*\.\s*[a-z][a-z0-9_$#]*\s*\(', re.I)
FUNC_RE = re.compile(r'(?<![.\w])([a-z][a-z0-9_$#]{2,60})\s*\(', re.I) # standalone function call, e.g. xx_item_attribute(
SQL_KEYWORDS = {
'select', 'where', 'and', 'or', 'group', 'order', 'by', 'having', 'union', 'all',
'from', 'join', 'on', 'as', 'case', 'when', 'then', 'else', 'end', 'null', 'not',
'in', 'exists', 'is', 'dual', 'distinct', 'left', 'right', 'outer', 'inner', 'full',
'connect', 'start', 'with', 'over', 'partition', 'rownum', 'decode', 'nvl', 'trunc',
'to_char', 'to_date', 'to_number', 'substr', 'instr', 'sum', 'count', 'max', 'min',
'avg', 'round', 'lower', 'upper', 'sysdate', 'userenv', 'set', 'values', 'into',
}
def strip_noise(sql):
"""remove block/line comments and string literals so we only scan real identifiers"""
sql = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.S)
sql = re.sub(r'--[^\n]*', ' ', sql)
sql = re.sub(r"'(?:''|[^'])*'", " '' ", sql)
return sql
def objects(sql, prefixes):
def startswith_prefix(name):
return any(name.startswith(p.lower()) for p in prefixes)
# dynamic SQL built in string literals (a BeforeReport lexical) can call customer packages
literals = ' '.join(re.findall(r"'((?:''|[^'])*)'", re.sub(r'--[^\n]*', ' ', re.sub(r'/\*.*?\*/', ' ', sql, flags=re.S))))
found = {m.group(1).lower() for r in (PKG_RE, FUNC_RE) for m in r.finditer(literals) if startswith_prefix(m.group(1).lower())}
sql = strip_noise(sql)
for m in FROM_RE.finditer(sql):
schema, obj = (m.group(1) or '').lower(), m.group(2).lower()
# a schema-qualified table hides a customer object when only the SCHEMA carries
# the prefix (xxc.some_table); keep the qualified name so it is not read as standard
if schema and startswith_prefix(schema) and not startswith_prefix(obj):
found.add('%s.%s' % (schema, obj))
else:
found.add(obj)
for m in PKG_RE.finditer(sql):
found.add(m.group(1).lower())
for m in FUNC_RE.finditer(sql):
if any(m.group(1).lower().startswith(p.lower()) for p in prefixes):
found.add(m.group(1).lower())
found = {f for f in found if f not in SQL_KEYWORDS and not f.isdigit()}
loc, cust, std, other = set(), set(), set(), set()
for f in found:
if f.startswith('jai_') or f.startswith('ja_in') or f.startswith('jai'):
loc.add(f)
elif any(f.startswith(p.lower()) for p in prefixes):
cust.add(f)
elif ORACLE_STD.match(f):
std.add(f)
else:
other.add(f)
return loc, cust, std, other
def branches(sql):
return len(re.findall(r'(?mi)^\s*union\s+all\s*$', strip_noise(sql))) + 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--custom', required=True, help='the gathered custom report SQL')
ap.add_argument('--shipped', help='the candidate shipped Blitz report SQL (from xxen_reports base table)')
ap.add_argument('--prefixes', default='XX', help='comma separated customer object prefixes')
a = ap.parse_args()
prefixes = [p.strip() for p in a.prefixes.split(',') if p.strip()]
csql = open(a.custom, encoding='utf-8', errors='replace').read()
cloc, ccust, cstd, cother = objects(csql, prefixes)
cbr = branches(csql)
print('=' * 78)
print('CLASSIFICATION PROBE')
print('=' * 78)
print('custom report : %s' % os.path.basename(a.custom))
print(' union branches : %d' % cbr)
print(' localization objects (%d) : %s' % (len(cloc), ', '.join(sorted(cloc)) or '-'))
print(' customer objects (%d) : %s' % (len(ccust), ', '.join(sorted(ccust)) or '-'))
print(' standard EBS objects (%d)' % len(cstd))
if cother:
print(' UNCLASSIFIED (%d) : %s' % (len(cother), ', '.join(sorted(cother))))
print(' ^ check these by hand - an unrecognised prefix hides customer objects')
sloc = scust = sstd = set()
sbr = 0
if a.shipped:
ssql = open(a.shipped, encoding='utf-8', errors='replace').read()
sloc, scust, sstd, _ = objects(ssql, prefixes)
sbr = branches(ssql)
print()
print('shipped report: %s' % os.path.basename(a.shipped))
print(' union branches : %d' % sbr)
print(' localization objects (%d) : %s' % (len(sloc), ', '.join(sorted(sloc)) or '-'))
print(' standard EBS objects (%d)' % len(sstd))
missing_loc = cloc - sloc
print()
print('-' * 78)
print('VERDICT')
print('-' * 78)
reasons = []
if missing_loc:
reasons.append('custom uses %d localization object(s) the shipped report does not: %s'
% (len(missing_loc), ', '.join(sorted(missing_loc))))
if ccust:
reasons.append('custom depends on %d customer-owned object(s): %s'
% (len(ccust), ', '.join(sorted(ccust))))
if a.shipped and cbr > sbr:
reasons.append('custom has %d union branches vs %d in the shipped report' % (cbr, sbr))
if reasons:
print(' OUTCOME 3 - build a new custom report')
for r in reasons:
print(' - %s' % r)
if ccust:
print()
print(' ACTION: confirm every customer object above has a gathered .sql dependency')
print(' file. If not, request the source from the customer BEFORE building')
print(' (DBA Text Search report) - the gather only scans the ONE prefix in')
print(' manifest.txt, so objects under any other naming are silently missing.')
rc = 3
else:
print(' Outcome 1/2 CANDIDATE - no localization or customer-object blockers found.')
print(' Now do the column diff to decide between 1 (complete map) and 2 (enhance),')
print(' comparing against ALL templates of the shipped report, not just one.')
rc = 0
print()
return rc
if __name__ == '__main__':
sys.exit(main())field_inventory.py
Show field_inventory.py
#!/usr/bin/env python3
"""Field inventory of an original Oracle report: every printed field traced to where the original computes it.
usage: field_inventory.py <program folder> [<PROG>] [--out <file.md>]
Reads what the gather put in the folder: <PROG>_rdf.xml (Oracle Reports, rwconverter XML) or <PROG>.rex (its text
export, when rwconverter fails), a BI Publisher data
template (*.xml with <dataTemplate>), the RTF layouts, and custom package bodies (*package_body.sql, *.pkb).
Writes <PROG>_field_inventory.md next to them. The Blitz expression and Proof columns are filled in during the
conversion; a printed field without a Blitz expression, and a row-control item without an implementation, is an
open defect (`custom-reports-migration` skill, "Value fidelity").
"""
import glob, html, os, re, sys
import xml.etree.ElementTree as ET
KEYWORDS = set("""select from where and or not null is in like between case when then else end nvl nvl2 decode to_char
to_number to_date trunc round substr substrb instr length upper lower trim ltrim rtrim replace sum count max min avg
sysdate distinct as dbms_xmlgen convert abs sign coalesce exists any all true false""".split())
FLOW = re.compile(r"^(for-each|for-each-group|end|if|choose|when|otherwise|call|template|import|sort|attribute|param|variable|split-by-page-break|section)\b", re.I)
def read(p):
with open(p, "rb") as f:
return f.read().decode("utf-8", "replace")
def strip_ns(root):
for e in root.iter():
if isinstance(e.tag, str) and "}" in e.tag:
e.tag = e.tag.split("}", 1)[1]
return root
def first_lines(text, n=3, width=160):
lines = [l.strip() for l in (text or "").splitlines() if l.strip()]
return " / ".join(lines[:n])[:width]
def can_return_false(body):
code = re.sub(r"/\*.*?\*/|--[^\n]*", " ", body or "", flags=re.S)
return re.search(r"return\s*\(?\s*false", code, re.I) is not None
def cell(s):
return str(s if s is not None else "").replace("|", "\\|").replace("\n", " ")
class Inventory:
def __init__(self, prog):
self.prog = prog
self.sources = {} # UPPER name -> (kind, definition)
self.units = {} # lower function name -> body
self.printed = [] # (name, use, context) in layout order
self.formulas = []
self.formula_unit = {} # UPPER formula name -> lower function name
self.dt_elements = [] # data template elements in document order
self.row_control = [] # (kind, object, detail)
self.queries = [] # (name, sql)
self.package_out = []
self.notes = []
# ---------- Oracle Reports (rwconverter XML) ----------
def rdf(self, path):
try:
root = strip_ns(ET.fromstring(read(path).encode("utf-8")))
except ET.ParseError as e:
self.notes.append("%s does not parse as XML (%s): read it by hand or use rex_parse.py." % (os.path.basename(path), e))
return
for fn in root.iter("function"):
src = fn.find("textSource")
self.units[(fn.get("name") or "").lower()] = src.text if src is not None and src.text else ""
for p in list(root.iter("userParameter")) + list(root.iter("systemParameter")):
self.sources[p.get("name", "").upper()] = ("parameter", p.get("initialValue") and "initial value %s" % p.get("initialValue") or "")
for ds in root.iter("dataSource"):
sel = ds.find("select")
sql = sel.text if sel is not None and sel.text else ""
self.queries.append((ds.get("name"), sql))
self._groups(ds, ds.get("name"), None)
# formulas, placeholders and summaries outside any group (report level)
data = root.find(".//data")
if data is not None:
for child in data:
self._column(child, "report level", None)
for link in root.iter("link"):
self.row_control.append(("link", "%s -> %s" % (link.get("parentGroup"), link.get("childQuery") or link.get("childGroup")),
"a filter or empty parent group suppresses the child query"))
for e in root.iter():
if e.tag == "advancedLayout":
continue
trig = e.get("formatTrigger")
if not trig and e.find("advancedLayout") is not None:
trig = e.find("advancedLayout").get("formatTrigger")
if trig:
body = self.units.get(trig.lower(), "")
if can_return_false(body):
self.row_control.append(("format trigger", "%s %s (source %s)" % (e.tag, e.get("name"), e.get("source")),
"%s can return FALSE: %s" % (trig, first_lines(re.sub(r"(?is)^.*?\bbegin\b", "", body, count=1)))))
for e in root.iter("field"):
self.printed.append((e.get("source") or e.get("name"), "paper layout field %s" % e.get("name"), ""))
for name, body in self.units.items():
if name in ("beforereport", "afterpform", "afterreport", "beforepform", "betweenpages") or name.endswith("trigger") and "format" not in name:
lex = sorted(set(re.findall(r":(\w+)\s*:=", body)))
self.row_control.append(("trigger", name, "assigns %s" % ", ".join(lex) if lex else first_lines(body, 2)))
def _formula(self, name, where, fn, datatype, null):
body = self.units.get(fn, "")
assigns = sorted(set(m.upper() for m in re.findall(r":(\w+)\s*:=", body)))
refs = sorted(set(m.upper() for m in re.findall(r":(\w+)", body)) - set(assigns) - {name})
into = len(re.findall(r"\binto\b", body, re.I))
handlers = [first_lines(h, 1, 80) for h in re.findall(r"(?is)\bwhen\s+(?:others|no_data_found|too_many_rows)\b.*?(?=\bwhen\b|\bend\b)", body)]
self.sources[name] = ("formula", "%s: %s%s%s" % (where, fn, ", " + datatype if datatype else "", ", null prints %s" % null if null else ""))
self.formulas.append((name, where, fn, datatype, null, assigns, refs, into, handlers))
self.formula_unit[name] = fn
def _groups(self, node, query, group):
for child in node:
if child.tag == "group":
g = child.get("name")
self._groups(child, query, g)
else:
self._column(child, query, group)
def _column(self, e, query, group):
name = (e.get("name") or "").upper()
where = "%s%s" % (query, " / " + group if group else "")
if e.tag == "dataItem":
d = e.find("dataDescriptor")
self.sources[name] = ("query column", "%s: %s" % (where, d.get("expression") if d is not None else ""))
elif e.tag == "formula":
self._formula(name, where, (e.get("source") or "").lower(), e.get("datatype"), e.get("valueIfNull"))
elif e.tag == "placeholder":
self.sources[name] = ("placeholder", "%s, %s width %s%s" % (where, e.get("datatype"), e.get("width"), ", null prints %s" % e.get("valueIfNull") if e.get("valueIfNull") else ""))
elif e.tag == "summary":
self.sources[name] = ("summary", "%s: %s(%s) reset %s compute %s" % (where, e.get("function"), e.get("source"), e.get("reset"), e.get("compute")))
elif e.tag == "filter":
if e.get("type") == "plsql":
body = self.units.get((e.get("plsqlFilter") or "").lower(), "")
self.row_control.append(("group filter", group, "%s: %s" % (e.get("plsqlFilter"), first_lines(re.sub(r"(?is)^.*?\bbegin\b", "", body, count=1)))))
else:
self.row_control.append(("group filter", group, "%s %s records" % (e.get("type"), e.get("numberOfRecords"))))
# ---------- Oracle Reports text export (.rex), when rwconverter fails ----------
def rex(self, path):
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import rex_parse
recs = rex_parse.parse(path)
by_id = {r.get("ITEMID"): r for r in recs if r.get("ITEMID")}
groups = {r.get("ITEMID"): r for r in recs if r["_type"] == "SRW2_GROUP"}
queries = {r.get("ITEMID"): r.get("NAME") for r in recs if r["_type"] == "SRW2_QUERY"}
owner = {}
for r in recs:
if r["_type"] == "SRW2_QUERY":
self.queries.append((r.get("NAME"), r.get("QUERYLFID") or ""))
elif r["_type"] == "TOOL_PLSQL":
self.units[(r.get("NAME") or "").lower()] = r.get("PLSLFID_ST") or ""
owner.setdefault(r.get("OBJECT_ID"), []).append(r)
for r in recs:
if r["_type"] != "SRW2_COLUMN":
continue
g = groups.get(r.get("GROUP_ID"), {})
where = "%s / %s" % (queries.get(g.get("QUERY_ID"), "report level"), g.get("NAME")) if g else "report level"
name = (r.get("NAME") or "").upper()
null = r.get("NULL_VALUE") if r.get("NULL_VALUE") not in (None, "NULLP") else None
units = [u for u in owner.get(r.get("ITEMID"), []) if u.get("TYPE") == "4"]
if units:
self._formula(name, where, units[0].get("NAME").lower(), "", null)
else:
self.sources.setdefault(name, ("column", "%s (query column, summary, placeholder or parameter: see the .rex)%s" % (where, ", null prints %s" % null if null else "")))
for r in recs:
if r["_type"] == "TOOL_PLSQL" and r.get("TYPE") != "4":
o = by_id.get(r.get("OBJECT_ID"), {})
body = r.get("PLSLFID_ST") or ""
if o.get("_type") in ("SRW2_FRAME", "SRW2_FIELD", "SRW2_BOILERPLATE"):
if can_return_false(body):
self.row_control.append(("format trigger", "%s %s" % (o["_type"][5:].lower(), o.get("NAME")),
"%s can return FALSE: %s" % (r.get("NAME"), first_lines(re.sub(r"(?is)^.*?\bbegin\b", "", body, count=1)))))
elif o.get("_type") == "SRW2_GROUP":
self.row_control.append(("group filter", o.get("NAME"), "%s: %s" % (r.get("NAME"), first_lines(re.sub(r"(?is)^.*?\bbegin\b", "", body, count=1)))))
elif r.get("TYPE") in ("7", "8", "9", "10"):
lex = sorted(set(re.findall(r":(\w+)\s*:=", body)))
self.row_control.append(("trigger", r.get("NAME"), "assigns %s" % ", ".join(lex) if lex else first_lines(body, 2)))
elif r["_type"] == "SRW2_LINK":
p, c = by_id.get(r.get("PARENT_ID"), {}), by_id.get(r.get("CHILD_ID"), {})
self.row_control.append(("link", "%s -> %s" % (p.get("NAME"), c.get("NAME")), "a filter or empty parent group suppresses the child"))
elif r["_type"] == "SRW2_FIELD":
src = by_id.get(r.get("SOURCE_ID"), {})
if src.get("NAME"):
self.printed.append((src.get("NAME"), "paper layout field %s" % r.get("NAME"), ""))
# ---------- BI Publisher data template ----------
def data_template(self, path):
try:
root = strip_ns(ET.fromstring(read(path).encode("utf-8")))
except ET.ParseError as e:
self.notes.append("%s does not parse as XML (%s)." % (os.path.basename(path), e))
return
if root.get("defaultPackage"):
self.notes.append("Data template %s uses default package %s: read its triggers and functions." % (os.path.basename(path), root.get("defaultPackage")))
for p in root.iter("parameter"):
self.sources[(p.get("name") or "").upper()] = ("parameter", "")
for q in root.iter("sqlStatement"):
self.queries.append((q.get("name"), q.text or ""))
for t in root.iter("dataTrigger"):
self.row_control.append(("data trigger", t.get("name"), t.get("source")))
for g in root.iter("group"):
if g.get("groupFilter"):
self.row_control.append(("group filter", g.get("name"), g.get("groupFilter")))
for el in g.findall("element"):
n = (el.get("name") or "").strip().upper()
kind = "summary" if el.get("function") else "query column"
self.sources[n] = (kind, "%s / %s: %s%s" % (g.get("source"), g.get("name"), (el.get("value") or "").strip(),
" %s()" % el.get("function") if el.get("function") else ""))
if g.get("name") != "G_PARAMETERS":
self.dt_elements.append(n)
for lk in root.iter("link"):
self.row_control.append(("link", "%s -> %s" % (lk.get("parentQuery"), lk.get("childQuery")), lk.get("condition") or ""))
# ---------- RTF layout ----------
def rtf(self, path):
text = read(path)
# a format mask's ';' and quotes are hex escapes, which must not glue the field name to the mask
text = re.sub(r"\\'([0-9a-fA-F]{2})", lambda m: bytes([int(m.group(1), 16)]).decode("cp1252", "replace"), text)
text = re.sub(r"\\(rquote|lquote)\s?", "'", text)
text = re.sub(r"\\(rdblquote|ldblquote)\s?", '"', text)
text = re.sub(r"\\[a-zA-Z]+-?\d* ?", "", text)
text = re.sub(r"[{}\r\n]", "", text)
# a program with an RTF layout prints the RTF; an RDF's old paper layout never reaches the output
self.printed = [p for p in self.printed if not p[1].startswith("paper layout field")]
seen = set(n for n, _, _ in self.printed)
for tok in re.findall(r"<\?(.{1,400}?)\?>", text):
tok = tok.strip()
names = [n.upper() for n in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", tok) if n.lower() not in KEYWORDS]
if FLOW.match(tok) or re.match(r"^(xdoxslt|xdofx|xdoxliff):", tok, re.I) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", tok):
known = [n for n in names if n in self.sources]
if re.match(r"^(if|choose|when)\b", tok, re.I) or "position()" in tok:
self.row_control.append(("layout condition", os.path.basename(path), tok[:200]))
elif re.match(r"^for-each", tok, re.I):
self.row_control.append(("layout loop", os.path.basename(path), tok[:200]))
elif known:
for n in known:
if n not in seen:
seen.add(n)
self.printed.append((n, "layout expression", tok[:120]))
continue
n = tok.upper()
if n not in seen:
seen.add(n)
self.printed.append((n, "layout field", os.path.basename(path)))
# ---------- PL/SQL package (execution method I, or a data template's package) ----------
def package(self, path, entry=None):
"""entry: the executable's procedure name; output is then read from that procedure only."""
text = read(path)
lines = text.splitlines()
lo, hi = 0, len(text)
if entry:
# the definition, not a forward declaration: 'is' or 'as' comes before the first semicolon
starts = [m.start() for m in re.finditer(r"(?im)^\s*procedure\s+%s\b" % re.escape(entry), text)
if re.search(r"(?i)\b(is|as)\b", text[m.end():text.find(";", m.end())])]
if not starts:
return
lo = starts[-1]
end = re.compile(r"(?im)^\s*end\s+%s\s*;" % re.escape(entry)).search(text, lo)
nxt = re.compile(r"(?im)^\s*(procedure|function)\s+\w+").search(text, lo + 10)
hi = end.end() if end else (nxt.start() if nxt else len(text))
self.notes.append("Output read from procedure %s of %s (lines %d-%d); procedures it calls that print are not followed: read them by hand." % (
entry, os.path.basename(path), text.count("\n", 0, lo) + 1, text.count("\n", 0, hi) + 1))
literal_cols = {}
for m in re.finditer(r"(?is)(?:select|,)\s*(null|0|-?\d+(?:\.\d+)?|'[^']*')\s+(?:as\s+)?([a-z_][a-z0-9_$#]*)\s*(?=,|\bfrom\b)", text):
literal_cols.setdefault(m.group(2).lower(), m.group(1))
inserts = sorted(set(m.lower() for m in re.findall(r"(?i)\binsert\s+into\s+([a-z0-9_$#.]+)", text[lo:hi])))
if inserts:
self.notes.append("%s inserts into %s: the rows the layout prints may be written by code, not selected." % (os.path.basename(path), ", ".join(inserts)))
def computed_at(expr):
where = []
for x in [x for x in re.findall(r"[A-Za-z_][A-Za-z0-9_$#]*(?:\.[A-Za-z_][A-Za-z0-9_$#]*)?", re.sub(r"'[^']*'", "", expr)) if x.lower() not in KEYWORDS]:
base = x.split(".")[-1].lower()
if "." in x:
if base in literal_cols:
where.append("%s is the cursor literal %s: filled by loop code?" % (x, literal_cols[base]))
continue
hits = [str(j) for j, l in enumerate(lines, 1) if re.search(r"(?i)\b%s\s*:=" % re.escape(x), l) or re.search(r"(?i)\binto\b.*\b%s\b" % re.escape(x), l)]
if hits:
where.append("%s assigned at line %s" % (x, ", ".join(hits[:12]) + (" ..." if len(hits) > 12 else "")))
return "; ".join(where)
def add(name, line, expr):
self.package_out.append((name, line, expr.strip()[:120], computed_at(expr)))
self.sources.setdefault(name.upper(), ("package output", "%s line %d: %s" % (os.path.basename(path), line, expr.strip()[:100])))
# XML elements: '<TAG>'||expr||'</TAG>'
for m in re.finditer(r"(?i)'\s*<(\w+)>\s*'\s*\|\|\s*(.+?)\s*\|\|\s*'\s*</\1>\s*'", text[lo:hi]):
add(m.group(1).upper(), text.count("\n", 0, lo + m.start()) + 1, m.group(2))
# XML elements written through a helper: <helper>('TAG',expr), where the helper builds '<'||tag||'>'
helpers = []
for m in re.finditer(r"(?im)^\s*procedure\s+(\w+)\s*\(", text):
body_end = re.compile(r"(?im)^\s*end\s+%s\s*;" % re.escape(m.group(1))).search(text, m.end())
if body_end and re.search(r"'<'\s*\|\|", text[m.end():body_end.start()]):
helpers.append(m.group(1))
for h in set(helpers):
for m in re.finditer(r"(?i)\b%s\s*\(\s*'(\w+)'\s*,\s*" % re.escape(h), text[lo:hi]):
start, depth, i = lo + m.end(), 1, lo + m.end()
while i < hi and depth:
depth += {"(": 1, ")": -1}.get(text[i], 0)
i += 1
add(m.group(1).upper(), text.count("\n", 0, lo + m.start()) + 1, text[start:i - 1])
# delimited text lines: a heading line of literals, then data lines of values joined with '|', ',' or a tab
headings = []
for m in re.finditer(r"(?i)\b(?:fnd_file\.put_line\s*\(\s*fnd_file\.output\s*,|fnd_file\.put\s*\(\s*fnd_file\.output\s*,|utl_file\.put_line\s*\(\s*\w+\s*,|utl_file\.put\s*\(\s*\w+\s*,)", text[lo:hi]):
start = lo + m.end()
expr, depth, q, i = None, 1, False, start
while i < hi:
c = text[i]
if q:
if c == "'":
if text[i + 1:i + 2] == "'":
i += 1
else:
q = False
elif c == "'":
q = True
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
expr = text[start:i]
break
i += 1
if expr is None or re.search(r"(?i)'\s*<\w+>", expr):
continue
pieces, cur, depth, q = [], "", 0, False
j = 0
while j < len(expr):
c = expr[j]
if q:
cur += c
if c == "'":
if expr[j + 1:j + 2] == "'":
cur += "'"
j += 1
else:
q = False
elif c == "'":
q = True
cur += c
elif c == "(":
depth += 1
cur += c
elif c == ")":
depth -= 1
cur += c
elif expr.startswith("||", j) and depth == 0:
pieces.append(cur.strip())
cur = ""
j += 1
else:
cur += c
j += 1
pieces.append(cur.strip())
delim = lambda p: re.fullmatch(r"(?i)chr\s*\(\s*(9|44|124)\s*\)|'[\s|,~;^\"\t]*'", p) is not None
literal = lambda p: re.fullmatch(r"'(?:[^']|'')*'", p) is not None
values = [p for p in pieces if p and not delim(p)]
line = text.count("\n", 0, start) + 1
if values and all(literal(p) for p in values):
heads = []
for p in values:
heads += [h.strip() for h in re.split(r"[|\t]|\\t", p[1:-1].replace("''", "'")) if h.strip()]
if len(heads) > 1:
headings = heads
continue
if len(values) < 2:
continue
for k, v in enumerate(values):
add(headings[k] if len(headings) == len(values) else "%s value %d" % (os.path.basename(path).split(".")[1] if "." in os.path.basename(path) else "output", k + 1), line, v)
# ---------- report ----------
def write(self, out):
printed = list(self.printed or [(t, "package output", "") for t, _, _, _ in self.package_out])
if not printed and self.dt_elements:
self.notes.append("No layout file in the folder: the printed fields below are the data template's elements. "
"Take the printed set, order and headings from the customer's layout or an ERP output.")
printed = [(n, "data template element", "") for n in self.dt_elements]
# a printed formula that concatenates other columns into a text line prints each of them
seen = set(n.upper() for n, _, _ in printed)
for n, _, _ in list(printed):
body = self.units.get(self.formula_unit.get(n.upper(), ""), "")
if body.count("||") >= 3:
for ref in re.findall(r":(\w+)", body):
if ref.upper() not in seen and ref.upper() in self.sources:
seen.add(ref.upper())
printed.append((ref, "printed inside %s" % n, ""))
w = []
a = w.append
a("# Field inventory: %s" % self.prog)
a("")
a("Every printed field, and everything that decides which rows print, traced to where the original computes it. Fill the "
"Blitz expression and Proof columns during the conversion; an empty Blitz expression or implementation is an open defect.")
a("")
for n in self.notes:
a("- " + n)
if self.notes:
a("")
a("## 1. Printed fields (%d)" % len(printed))
a("")
a("| # | Field | Where it is used | Source in the original | Blitz expression | Proof |")
a("|---|---|---|---|---|---|")
for i, (n, use, ctx) in enumerate(printed, 1):
kind, d = self.sources.get(n.upper(), ("UNRESOLVED", "not found in the data model: read the source"))
a("| %d | %s | %s | %s: %s | | |" % (i, cell(n), cell(use if not ctx else "%s (%s)" % (use, ctx)), kind, cell(d)))
a("")
a("## 2. Formula columns (%d)" % len(self.formulas))
a("")
a("Each needs its SQL equivalent, including what its exception handlers print when a lookup finds no row or several, "
"and every placeholder it assigns.")
a("")
a("| Formula | Group | Function | Returns | Null prints | Assigns | Reads | select into | Handlers | Blitz expression |")
a("|---|---|---|---|---|---|---|---|---|---|")
for f in self.formulas:
a("| %s | %s | %s | %s | %s | %s | %s | %d | %s | |" % (cell(f[0]), cell(f[1]), cell(f[2]), cell(f[3]), cell(f[4]),
cell(", ".join(f[5])), cell(", ".join(f[6])[:200]), f[7], cell("; ".join(f[8])[:200])))
a("")
a("## 3. Row and section control (%d)" % len(self.row_control))
a("")
a("Group filters, links, format triggers that can return FALSE, layout conditions and loops, and triggers that build "
"lexicals decide which rows and sections print. Each needs an implementation or a stated reason why it never fires.")
a("")
a("| Kind | Object | Detail | Implementation |")
a("|---|---|---|---|")
for k, o, d in self.row_control:
a("| %s | %s | %s | |" % (cell(k), cell(o), cell(d)))
a("")
a("## 4. Queries (%d)" % len(self.queries))
a("")
a("A `union` de-duplicates over its whole select list, ids and rowids included: keep the original's operator and key. "
"Lexicals are filled by the triggers listed above.")
a("")
a("| Query | union | union all | order by | Lexicals |")
a("|---|---|---|---|---|")
for n, sql in self.queries:
s = re.sub(r"--[^\n]*|/\*.*?\*/", " ", sql or "", flags=re.S)
s = re.sub(r"'[^']*'", "''", s)
a("| %s | %d | %d | %s | %s |" % (cell(n), len(re.findall(r"(?i)\bunion\b(?!\s+all\b)", s)), len(re.findall(r"(?i)\bunion\s+all\b", s)),
"yes" if re.search(r"(?i)\border\s+by\b", s) else "no", cell(", ".join(sorted(set(re.findall(r"&(\w+)", s)))))))
if self.package_out:
a("")
a("## 5. Package output elements (%d)" % len(self.package_out))
a("")
a("Values the program prints from variables are computed by its loop code, not by the cursor: replicate those "
"assignments. A cursor literal (`0 basic`, `null source`) is a placeholder the loop fills.")
a("")
a("| Element | Line | Expression | Computed at | Blitz expression |")
a("|---|---|---|---|---|")
for t, i, e, wh in self.package_out:
a("| %s | %d | %s | %s | |" % (cell(t), i, cell(e), cell(wh)))
with open(out, "w", encoding="utf-8", newline="\n") as fh:
fh.write("\n".join(w) + "\n")
unresolved = sum(1 for n, _, _ in printed if n.upper() not in self.sources)
print("%s: %d printed fields (%d unresolved), %d formulas, %d row-control items, %d queries, %d package output elements -> %s" % (
self.prog, len(printed), unresolved, len(self.formulas), len(self.row_control), len(self.queries), len(self.package_out), out))
def main():
args = sys.argv[1:]
out = None
if "--out" in args:
i = args.index("--out")
out = args[i + 1]
del args[i:i + 2]
if not args:
sys.exit(__doc__)
folder = args[0]
prog = args[1] if len(args) > 1 else os.path.basename(os.path.normpath(folder))
inv = Inventory(prog)
# the RDF is named after the executable, which can differ from the program short name
rdf = next(iter([os.path.join(folder, prog + "_rdf.xml")] if os.path.exists(os.path.join(folder, prog + "_rdf.xml")) else sorted(glob.glob(os.path.join(folder, "*_rdf.xml")))), None)
rex = next(iter(sorted(glob.glob(os.path.join(folder, "*.rex")))), None)
if rdf:
inv.rdf(rdf)
elif rex:
inv.rex(rex)
elif glob.glob(os.path.join(folder, "*.rdf")):
inv.notes.append("No _rdf.xml or .rex: rwconverter failed. Ask the customer for the .rex text export (Reports Builder: File > Convert) and put it in this folder.")
for x in glob.glob(os.path.join(folder, "*.xml")):
if x != rdf and "<dataTemplate" in read(x)[:5000]:
inv.data_template(x)
# a PL/SQL program's output comes from its executable: PKG.PROCEDURE, or a standalone procedure
pkg = proc = None
ldt = os.path.join(folder, prog + ".ldt")
if os.path.exists(ldt):
m = re.search(r'(?s)BEGIN EXECUTABLE .*?EXECUTION_METHOD_CODE = "(\w)".*?EXECUTION_FILE_NAME = "([^"]+)"', read(ldt))
if m and m.group(1) == "I":
parts = m.group(2).split(".")
pkg, proc = (parts[-2].upper(), parts[-1]) if len(parts) > 1 else (None, parts[-1])
code = sorted(glob.glob(os.path.join(folder, "*package_body.sql")) + glob.glob(os.path.join(folder, "*.pkb")) + glob.glob(os.path.join(folder, "*procedure.sql")))
entry_files = [p for p in code if pkg and ".%s." % pkg in os.path.basename(p).upper() or not pkg and proc and ".%s." % proc.upper() in os.path.basename(p).upper()]
for p in entry_files or code:
inv.package(p, proc if pkg and p in entry_files else None)
for r in sorted(glob.glob(os.path.join(folder, "*.rtf"))):
inv.rtf(r)
inv.write(out or os.path.join(folder, prog + "_field_inventory.md"))
if __name__ == "__main__":
main()references/fidelity-audit-prompt.md
# Independent fidelity audit: agent prompt Give this prompt to a second agent (a subagent, or Codex with `codex exec`) before the first delivery of a converted report. The second agent must not see the converter's reasoning, only the files. Fill in the two paths. --- READ-ONLY AUDIT. Do not edit or create any file, do not connect to any database, do not propose rewritten code. Original program files: `<gather folder of the program>`: the `_rdf.xml` or `.rex`, the data template, the RTF layout, the custom package bodies and the `.ldt`. Converted Blitz Report: `<report XML>`. The SQL is the first `<SQL_TEXT>` after `<GUID>`. Each `<PARAMETERS_ROW>`'s `<SQL_TEXT>` is injected at its anchor (`1=1`, `2=2`, `&lexical`); a `:bind` is bound directly. Enumerate EVERY value or row rule the original produces that reaches the printed output (an RTF field, a paper-layout field, an element the package writes), including everything it computes outside its main query: - formula columns (`CF_`), and placeholders (`CP_`) assigned inside formulas; - summaries (`CS_`) that a formula or the layout reads; - format triggers that return FALSE, and group filters, including one on a parent group that suppresses its linked child queries; - BeforeReport and AfterParameterForm triggers that build lexicals or derive binds; - BI Publisher data triggers, group filters and element expressions; - for a PL/SQL program, every variable its loop writes to the output. A cursor column selected as a literal (`0 basic`, `null source`) is a placeholder the loop fills later. Check every branch of a `union`, and the `union` versus `union all` key. For each item give: - **Item:** the name and the output field it feeds. - **Original:** what it computes, `file:line`, the essential code quoted briefly. - **Blitz:** the equivalent, `file:line` in the XML, or `none`. - **Verdict:** OK (same result), DIFFERENT (computed differently: say how), or MISSING. - **Consequence:** what the user sees for DIFFERENT or MISSING, such as a blank column, zero amount, wrong sign, rows shown that should be hidden, or a wrong filter. Check the edge behaviour too: - what the original prints when a lookup finds no row or several; - declared widths and number precision; - `nvl` defaults and `valueIfNull`; - dates printed as text; - row order and row grain; - serial numbers and running totals; - report modes; - parameters: what an empty optional parameter does to rows whose filtered column is null (`col=nvl(:p,col)` drops them), a value equal to an `nvl()` stand-in, a free-text value set that is upper case only, the exact date boundaries, which id or value each list passes; - name capture in PL/SQL cursors: a predicate that compares a column with an identifier that is both a column of the queried view and a procedure parameter. Ignore pure layout: fonts, page headers, the run date and parameter echo lines. Output: markdown, a table `Item | Original (file:line) | Blitz (file:line) | Verdict | Consequence` listing every output-relevant item, OK ones briefly. End with `<report>: N items, X DIFFERENT, Y MISSING`. If unsure whether two expressions are equivalent, say DIFFERENT and state what would need to be checked. --- ## Fix brief, when a round of corrections is delegated Give the fixing agent the audit's table and these working rules: - Start from the currently delivered definition, not an older file. - Correct each DIFFERENT and MISSING item to what the original prints. Re-check each item against the source first. If the audit is wrong, say so and leave the item. - Work in its own folder: the build script, the resulting XML, and a NOTES.md with one row per item: FIXED with how and the proof, NOT FIXED with why, or AUDIT WRONG. - Pass the value fidelity gate (`value-fidelity.md`) on the result, including the full-fetch cost, before reporting. - No commits, no DML or DDL on shared instances; prefix every uploaded file with the report id.
references/value-fidelity.md
# Value fidelity gate
A converted report is delivered only when it is proven to print what the original prints, for the same data and parameters. Parsing, running, returning rows, matching parameters and matching headings are necessary and prove none of this. The customer's UAT is not the comparison: every defect it finds costs a round.
The gate applies to the first delivery and to every correction. Record each result in the Proof column of the report's field inventory.
## Inputs
- The field inventory, `field_inventory.py <program folder>`, with every row's Blitz expression filled in.
- The Blitz report imported on the conversion instance.
- The customer's reference output: one ERP output of the original program with its parameters (step 1 of the skill). An earlier Blitz output is never a reference.
## Checks
1. **Inventory complete.** Every printed field (section 1), formula (2), row-control item (3) and package output element (5) has a Blitz expression or implementation. The only other acceptable entry is a reason the item never reaches the output, citing the source: a formula the layout does not print, a trigger that returns TRUE for every value the program's parameters allow. An empty cell is an open defect.
2. **Formula port.** The original's formula functions and per-row package code, ported as static PL/SQL with their binds as arguments, run on every row of the Blitz SQL and are compared with the Blitz columns: 0 differences on every column (skeleton A). Where the instance has no data for a path, feed identical stand-in rows to both sides (skeleton D).
3. **Reference output.** Checked against the customer's ERP output:
- The parsed columns match its columns, order and headings, apart from the rendering limits in the rules.
- Every relation the file lets you recompute holds on every row: totals equal their components, FC equals IC times rate, ages equal as-of minus due date, bucket sums equal outstanding.
- Row grain matches: one row per what the ERP prints one row for, with the same row order.
4. **Old against new** (a correction of a delivered report): every column the change does not intend to alter has 0 differences, rows joined on the report's key, and `minus` both ways on the key set (skeleton B). A performance rewrite that replaces correlated sub-queries with aggregates is also checked sub-query by sub-query: each aggregate's key must equal the sub-query's correlation, outer-join `(+)` predicates included, because the instance may hold no row that exercises the difference (an adjustment on an invoice with several instalments).
5. **Full-fetch cost.** Fetch every output column of old and new over the same window, twice, and report elapsed seconds and buffer gets per row from the second run (skeleton C). A `count(*)` is not a measurement, because Oracle prunes the columns it does not reference. A new column that multiplies the cost is tuned before delivery.
6. **Independent audit** before the first delivery: a second agent, given only the original's files and the Blitz XML, lists every item and its verdict (`fidelity-audit-prompt.md`). Every DIFFERENT or MISSING is fixed or answered from the source before the file goes out.
## Edge behaviour the original prints
Reproduce these exactly. They are where conversions silently diverge.
- **Single-row lookup** (`select ... into` in a formula): 0 or 2+ rows prints what the exception handler returns, usually blank or 0. Write it as `case when count(*)=1 then max(x) end`. When the handler does not reset the variable, PL/SQL leaves an undefined value that is in practice the first fetched row. Measure the shape on the instance before choosing: kept for a group-by and a unique-key join and in the local of an RDF `into` that mixes a local and a bind placeholder, not kept for an `in` list or a plain join into locals only. Find lines that really reach the path, restricted as the original's secured views are, and compare those.
- **Declared widths:** a `varchar2(n)` placeholder or variable overflows into its handler, so the value prints blank. `number(p,s)` rounds.
- **Null defaults:** `nvl` defaults in the code (`'NA'`, `0`, `'No'`) and the RDF column's `valueIfNull`.
- **Dates:** a date the original prints as text in a fixed mask stays text in that mask. A date it prints as a date stays a date, without time where the original truncates.
- **Row order:** the query's `order by`, else the group break order.
- **Row grain:** group and break keys, and the `union` key, which is the whole select list including ids and rowids the layout never prints.
- **Per-row numbering and running totals** the layout prints: `row_number()` and analytic `sum()` over the report's order.
- **Report modes:** a summary option, sections switched by a parameter, and a group filter that suppresses a whole section each print what the original prints.
- **Header values** printed once (legal entity, customer on a ledger) become a column on every row.
- **Empty and odd parameter values:** with an empty parameter `col=nvl(:p,col)` drops the rows whose column is null; a value equal to the original's `nvl()` stand-in also matches the empty rows; an upper-case-only value set upper-cases free text. Each is a test case of the proof, not only the filled, ordinary value.
- **Whether the test reached the code:** count the rows that took each path before trusting 0 differences. Two zero-row runs match, and data that never takes a branch proves nothing about it (an instance's plan whose shipments never matched left the claiming function unused: 0 claimed rows, 2,636 rows compared). Build stand-in rows for each path and feed them to both sides. Compare under a responsibility the report is assigned to, since secured views return nothing under System Administrator.
- **An original that differs from itself:** when it takes rows in an order nothing fixes (no `order by`, a heap table refilled per run), run the original several times, alternating with the conversion, and compare every pair. The original against itself is the noise level, and only a difference above it is a defect. Assume neither way: a difference that looked like that noise was caused by a correction, and it showed only because the original matched itself on the same data. Compare by position as well as by value, so a reordered output cannot read as changed values.
- **What the layout actually reads:** Oracle Reports writes a column under its XML tag, which can differ from the column name, and an RTF field whose name matches no tag prints blank. When the source cannot settle what the original prints, run it: the data template and RTF through the BI Publisher engine on the instance, or the package's own procedure into its session temp table and roll back.
## Skeletons
Upload the Blitz SQL as a file and read it into a clob from the `TMP` directory. Resolve the anchors as a run would: replace each `n=n` with its predicates, and each `:bind` with a literal. Keep `dbms_output` lines under 255 bytes. A script that pastes a data-model query verbatim into SQLcl or SQL*Plus needs `set sqlblanklines on`: a blank line otherwise ends the statement (`ORA-00907` at its last line). A check is only proof when it returns rows: choose filter and security values that exist in the test data, and count the non-zero values per column so a measure that is zero on both sides is visible.
**A. Formula port**, one function per ported formula, compared per row:
```sql
set serveroutput on size 1000000
declare
l_sql clob:=<fread('p_new.sql')>;
l_cur sys_refcursor;
r_invoice_id number;
r_blitz_col varchar2(4000);
l_diff pls_integer:=0;
l_rows pls_integer:=0;
--the original's formula, its binds as parameters, its select into and handler unchanged
function cf_x(p_invoice_id number) return varchar2 is
l_v varchar2(240);
begin
select ... into l_v from ... where ...=p_invoice_id;
return l_v;
exception when others then
return null;
end;
begin
open l_cur for 'select x.invoice_id, x."Heading" from ('||l_sql||') x';
loop
fetch l_cur into r_invoice_id, r_blitz_col;
exit when l_cur%notfound;
l_rows:=l_rows+1;
if nvl(r_blitz_col,'<null>')<>nvl(cf_x(r_invoice_id),'<null>') then
l_diff:=l_diff+1;
if l_diff<=3 then
dbms_output.put_line('DIFF '||r_invoice_id||' blitz ['||r_blitz_col||'] original ['||cf_x(r_invoice_id)||']');
end if;
end if;
end loop;
dbms_output.put_line('rows '||l_rows||', Heading differences '||l_diff);
end;
/
```
Carry a key column (`invoice_id`) through the Blitz SQL for the harness only when the output does not already have one, and drop it from the delivered report.
**B. Old against new**, per-column difference counts on joined rows:
```sql
select
count(*) joined,
sum(decode(o.col1,n.col1,0,1)) col1,
sum(decode(o.col2,n.col2,0,1)) col2
from
(<old sql>) o,
(<new sql>) n
where
o.key=n.key
```
`decode` treats two nulls as equal. Run `select key from (<old>) minus select key from (<new>)` and the reverse, which must both be empty unless the change intends to add or remove rows.
**C. Full fetch.** Parse with `dbms_sql`, `describe_columns3`, define every column as `varchar2(4000)` (a date and number column converts), fetch every row, and read `session logical reads` from `v$mystat` before and after. Elapsed time comes from `dbms_utility.get_time`. Run old and new alternately, twice each, and report the second run.
**D. Stand-in data**, for a table the instance has no rows for, such as India Localization tax lines or the customer's own tables. Replace the table with the same inline view in both the ported original and the Blitz SQL:
```sql
(
select 101 invoice_id, 'TDS/CM/1' dm_invoice_num from dual union all
select 102, 'TDS/CM/1' from dual union all
select 103, 'TDS/CM/2' from dual
) jati
```
Cover no match, one match, two or more matches with different values, a value in another operating unit, and a boundary such as the length limit or an invalid date. 0 differences on every case.rex_parse.py
Show rex_parse.py
#!/usr/bin/env python
"""Parse an Oracle Reports .rex text export (Reports Builder: File > Convert, "Report Definition file" -> "Text file")
into its DEFINE records, decoding <<"...">> text and (BINARY)/(TLONG) blocks.
Fallback when rwconverter segfaults on the .rdf (no <PROG>_rdf.xml): ask the customer for the .rex and run
python rex_parse.py <folder-with-rex> <PROG> [<PROG> ...]
which prints the record-type counts, every SRW2_QUERY SQL, every non-trivial TOOL_PLSQL unit and the
parameter columns (SRW2_COLUMN FUNCTION=3), and writes <PROG>_<QUERY>.sql + <PROG>_plsql.txt next to the .rex.
Query variants that differ only in sort/group expressions collapse into one Blitz SQL; the LDT stays the parameter source.
"""
import re,io,sys,os,collections
def parse(path):
with io.open(path,encoding='latin-1') as f: lines=f.read().split('\n')
recs=[];i=0;n=len(lines)
while i<n:
m=re.match(r'^DEFINE\s+(\S+)',lines[i])
if not m: i+=1; continue
typ=m.group(1); i+=1
assert lines[i].strip()=='BEGIN',(path,i,lines[i]); i+=1
rec={'_type':typ}
while i<n and lines[i].strip()!='END':
l=lines[i]
fm=re.match(r'^\s*(\w+)\s*=\s*(.*)$',l)
if fm:
k,v=fm.group(1),fm.group(2).strip()
if v.startswith('(') and v.endswith(')') or v=='':
# value follows as <<" ... ">> block
i+=1
buf=[]
if lines[i].startswith('<<"'):
first=lines[i][3:]
if first.endswith('">>'):
buf.append(first[:-3]); i+=1
else:
buf.append(first); i+=1
while i<n and not lines[i].endswith('">>'):
buf.append(lines[i]); i+=1
buf.append(lines[i][:-3]); i+=1
raw='\n'.join(buf)
if v=='(BINARY)':
hx=re.sub(r'\s+','',raw)
try: txt=bytes.fromhex(hx).decode('latin-1').rstrip('\x00')
except Exception: txt=raw
rec[k]=txt
else:
rec[k]=raw
continue
if v.startswith('<<"'):
if v.endswith('">>'): rec[k]=v[3:-3]; i+=1; continue
buf=[v[3:]]; i+=1
while i<n and not lines[i].endswith('">>'):
buf.append(lines[i]); i+=1
buf.append(lines[i][:-3]); i+=1
rec[k]='\n'.join(buf); continue
rec[k]=v.strip()
i+=1
recs.append(rec); i+=1
return recs
if __name__=='__main__':
M=sys.argv[1]
for prog in sys.argv[2:]:
recs=parse(os.path.join(M,prog+'.rex'))
print('#'*20,prog,len(recs),'records')
c=collections.Counter(r['_type'] for r in recs)
print({k:v for k,v in c.items() if not k.startswith('VG_')})
plsql=[]
for r in recs:
t=r['_type']
if t=='SRW2_QUERY':
sql=next((v for k,v in r.items() if isinstance(v,str) and len(v)>40 and 'select' in v.lower()),None)
fn=os.path.join(M,'%s_%s.sql'%(prog,r.get('NAME')))
if sql:
with io.open(fn,'w',encoding='utf-8',newline='\n') as f: f.write(sql)
print('--- QUERY',r.get('NAME'),'->',fn,'(%d chars)'%len(sql or ''))
if t=='TOOL_PLSQL':
src=r.get('PLSLFID_ST') or ''
plsql.append('=== PLSQL %s type %s\n%s'%(r.get('NAME'),r.get('TYPE'),src))
body=re.sub(r'/\*.*?\*/','',src,flags=re.S); body=re.sub(r'--[^\n]*','',body)
lines=[l for l in body.splitlines() if l.strip()]
trivial=len(lines)<=6 and re.search(r'return\s*\(?\s*(true|false)\s*\)?\s*;',body,re.I) and not re.search(r'select|:=',body,re.I)
print('--- PLSQL',r.get('NAME'),'type',r.get('TYPE'),'src',len(src),'(trivial)' if trivial else '')
if t in ('SRW2_PARAMETER','SRW2_USER_PARAM','SRW2_PARAM'):
print('--- PARAM',{k:v for k,v in r.items() if k in ('NAME','DATA_TYPE','INIT_VALUE','WIDTH','PARAM_TYPE')})
if t=='SRW2_COLUMN' and str(r.get('FUNCTION'))=='3':
print('--- PARAM',r.get('NAME'),'| datatype',r.get('DATA_TYPE'),'| width',r.get('WIDTH'))
with io.open(os.path.join(M,prog+'_plsql.txt'),'w',encoding='utf-8',newline='\n') as f: f.write('\n'.join(plsql))verify_assignments.py
Show verify_assignments.py
#!/usr/bin/env python
"""Offline assignments gate for the custom-reports migration (Phase 2).
Asserts that the <ASSIGNMENTS> block of each built report XML reproduces the gathered
<PROG>_assignments.xml EXACTLY - every request group carried, each with its OWN application
short name, and the level code normalised for the target instance.
Needs NO database. That is the point: on a conversion instance the customer's request groups
do not exist, so the import legitimately reports 0 assignments and can tell you nothing about
whether the XML was right. A zero there is UNVERIFIABLE, not PASSED - accepting it lets a
defect ship (a few of the groups carried per report, all stamped with the customer's custom
application instead of JA / SQLAP / AR / JE / SQLGL).
LEVEL CODE
Builds before 21-Jun-2026 use single letters; from that build on the import
decodes only the full words and SILENTLY SKIPS anything else. Gathered packages produced before
the converter fix carry 'G'. Legacy mapping:
S->Site A->Application G->Request Group R->Responsibility
U->User F->Form D->Report (Function has no legacy letter)
USAGE
python verify_assignments.py --gather <CONV_folder> --built <folder-with-built-XMLs> \
--map map.json [--level "Request Group"]
map.json maps each built XML file to its source program(s). The deliverable is ONE category file,
so the normal shape maps that file to {"<Blitz report name>": "<PROG>", ...} and the gate checks each
report's rows separately; a plain string value (one XML per program) is still accepted:
{"reports_Custom_Reports_ABC_with_assignments_v01.xml": {"ABC Item Extract": "XX_ITEM_EXTRACT",
"ABC Asset Detail": "XX_ASSET_DETAIL"}}
Exit code 0 = gate passed, 1 = gate failed.
"""
import argparse
import html
import json
import os
import re
import sys
LEGACY = {'S': 'Site', 'A': 'Application', 'G': 'Request Group', 'R': 'Responsibility',
'U': 'User', 'F': 'Form', 'D': 'Report'}
def rows(path, report_name=None):
"""(level_value, application_short_name, assignment_level) per ASSIGNMENTS_ROW, optionally of one report"""
x = open(path, encoding='utf-8').read()
out = []
for m in re.finditer(r'<ASSIGNMENTS_ROW>(.*?)</ASSIGNMENTS_ROW>', x, re.S):
r = m.group(1)
rn = re.search(r'<REPORT_NAME>(.*?)</REPORT_NAME>', r, re.S)
if report_name is not None and (not rn or html.unescape(rn.group(1)) != report_name):
continue
lv = re.search(r'<LEVEL_VALUE>(.*?)</LEVEL_VALUE>', r, re.S)
ap = re.search(r'<APPLICATION_SHORT_NAME>(.*?)</APPLICATION_SHORT_NAME>', r, re.S)
al = re.search(r'<ASSIGNMENT_LEVEL>(.*?)</ASSIGNMENT_LEVEL>', r, re.S)
out.append((html.unescape(lv.group(1)) if lv else '',
ap.group(1) if ap else '',
al.group(1) if al else ''))
return out
def normalise(level):
return LEGACY.get(level, level)
def main():
p = argparse.ArgumentParser()
p.add_argument('--gather', required=True, help='extracted CONV_<ABBR>_... folder')
p.add_argument('--built', required=True, help='folder holding the built report XMLs')
p.add_argument('--map', required=True, help='JSON: {"<built xml>": "<PROG>"}')
p.add_argument('--level', default='Request Group',
help='level code the TARGET instance expects (default: full words)')
a = p.parse_args()
mapping = json.load(open(a.map, encoding='utf-8'))
fail = 0
checks = []
for xml in sorted(mapping):
if isinstance(mapping[xml], dict):
checks += [(xml, name, prog) for name, prog in sorted(mapping[xml].items())]
else:
checks.append((xml, None, mapping[xml]))
for xml, name, prog in checks:
label = (name or xml)[:50]
bpath = os.path.join(a.built, xml)
gpath = os.path.join(a.gather, prog, prog + '_assignments.xml')
if not os.path.exists(gpath):
print('%-52s NO GATHERED FILE (%s)' % (label, gpath))
fail += 1
continue
built, gathered = rows(bpath, name), rows(gpath)
b = {(v, ap) for v, ap, _ in built}
g = {(v, ap) for v, ap, _ in gathered}
bad_level = {l for _, _, l in built} - {a.level}
ok = not (g - b) and not (b - g) and not bad_level
print('%-52s %2d/%2d %s' % (label, len(built), len(gathered), 'OK' if ok else 'FAIL'))
for v, ap in sorted(g - b):
print(' MISSING from built XML : %s @ %s' % (v, ap)); fail += 1
for v, ap in sorted(b - g):
print(' NOT IN gathered file : %s @ %s' % (v, ap)); fail += 1
for l in sorted(bad_level):
hint = ' (legacy for %s - normalise it)' % normalise(l) if l in LEGACY else ''
print(' WRONG level code : %s, expected %s%s' % (l, a.level, hint)); fail += 1
print()
print('ASSIGNMENTS GATE PASSED' if not fail else 'ASSIGNMENTS GATE FAILED (%d problems)' % fail)
return 1 if fail else 0
if __name__ == '__main__':
sys.exit(main())vs_parse.py
Show vs_parse.py
"""Print the value-set definitions from converter *_vs_*.ldt files (table, id/value columns, where clause)."""
import glob,re,sys
def attr(t,k):
i=t.find(k+" =")
if i<0: return None
j=t.index('"',i)+1; out=[]
while True:
c=t[j]
if c=="\\": out.append(t[j+1]); j+=2; continue
if c=='"': break
out.append(c); j+=1
return "".join(out).replace("\n","\n")
for f in sorted(glob.glob("*vs_*.ldt")):
t=open(f,encoding="utf-8",errors="replace").read()
print("==",re.search(r'BEGIN VALUE_SET "([^"]+)"',t).group(1),"validation",attr(t,"VALIDATION_TYPE"))
for k in ("TABLE_NAME","ID_COLUMN_NAME","VALUE_COLUMN_NAME","MEANING_COLUMN_NAME","ADDITIONAL_WHERE_CLAUSE"):
v=attr(t,k)
if v: print(" ",k,"=",re.sub(r"\s+"," ",v)[:900])