This commit is contained in:
Asyncronize 2026-11-04 10:22:57 +00:00 • committed by GitHub
commit 56407fff85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 358872 additions and 19 deletions

View file

@ -1055,6 +1055,14 @@ jobs:
run: node --import tsx bench/scope-capture/measure.mjs --check
working-directory: gitnexus
- name: PL/SQL parse fingerprint + scaling guard
if: ${{ !cancelled() }}
# Build-free: asserts extractPlSqlFacts output over the plsql-* fixtures
# is unchanged (fingerprint) and the warmed ANTLR parse stays linear in
# package-body size (scaling < 1.5). Timings are reported, not gated.
run: node --import tsx bench/plsql-parse/measure.mjs --check
working-directory: gitnexus
- name: Rename output construction scaling guard
if: ${{ !cancelled() }}
run: node --import tsx bench/rename/measure.mjs --check

View file

@ -1,6 +1,8 @@
dist/
coverage/
gitnexus/vendor/
# ANTLR4-generated PL/SQL lexer/parser (regenerate, never hand-edit).
gitnexus/src/core/ingestion/plsql/generated/
gitnexus/test/fixtures/
# Line/column-sensitive callable identity and taint regression.
gitnexus/test/integration/cfg/fixtures/inline-taint-repo/

View file

@ -85,7 +85,7 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th
19 default phases are defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. `--pdg` adds `taintSummaries` and `callSummaries` (21 total).
```
scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm]
scan → structure → [springConfig, markdown, cobol, plsql] → parse → [routes, tools, orm]
→ crossFile → scopeResolution → [springAutoConfiguration, springAop]
→ pruneLocalSymbols → mro → springAopInheritance → di → communities → processes
```
@ -97,7 +97,8 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
| `springConfig` | `spring-config.ts` | `structure` | Spring configuration-property nodes and metadata |
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
| `plsql` | `plsql.ts` | `structure` | Oracle PL/SQL package/subprogram/trigger/table nodes; CALLS/ACCESSES/IMPORTS edges (ANTLR4) |
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol`, `plsql` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS static route sources — see below) |
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
@ -568,7 +569,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
## Language-agnostic graph feeding
18 languages → single unified graph. Four abstraction layers:
19 languages → single unified graph. Four abstraction layers:
```
Unified Graph Schema (44 node types, 21 relationship types)

View file

@ -112,8 +112,8 @@ For Docker, hosted deployments, image verification, or a local frontend, see
## Supported languages
C, C++, C#, COBOL, Dart, Go, Java, JavaScript, Kotlin, Objective-C, PHP, Python,
Ruby, Rust, Swift, TypeScript, Vue and Zig have registered providers. Support
C, C++, C#, COBOL, Dart, Go, Java, JavaScript, Kotlin, Objective-C, Oracle PL/SQL,
PHP, Python, Ruby, Rust, Swift, TypeScript, Vue and Zig have registered providers. Support
varies by construct; see the [language inventories](docs/languages/README.md)
for measured behavior, exclusions and remaining gaps.

View file

@ -288,6 +288,7 @@ For local model setup and switching instructions, see [Local Embedding Model](..
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Per-bucket byte budget for parse-cache packing. Files are grouped by `(language, hash(path) mod 128)`; packs inside a bucket are cut at this limit. Smaller = finer-grained invalidation and more dispatch. Default is always 2 MiB and no longer scales with worker count. | Tuning incremental-analyze cache invalidation on monorepos without changing `--workers`. |
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When exactly `1`, skips optional vendored Dart, Proto, Swift, Kotlin and Zig grammar setup. The C grammar and other native dependencies remain required. | Skipping optional language parsers when their prebuilds are unavailable and no source-build toolchain is installed. |
| `GITNEXUS_PLSQL_SEGMENT_DEADLINE_MS` | `10000` | Floor, in ms, of the wall-clock budget for parsing one `/`-terminated PL/SQL segment; the budget grows by 15 ms per line above it. A segment that runs out is recorded as timed out and its definitions come from the regex fallback (low confidence; rename refuses on them). `0` disables the deadline; blank, invalid or negative values use the default. | PL/SQL repositories with very large package bodies on slow or loaded machines, where segments hit the deadline (the `PL/SQL degraded: … at the parse deadline` line `analyze` logs with `NODE_ENV=development`) and lose call edges to the fallback. |
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
| `GITNEXUS_MCP_ENABLE_REFRESH` | unset | Set to `1` to list the `refresh_index` MCP tool on a local `gitnexus mcp` server (stdio or `--http`). `gitnexus serve` never exposes it, read-only mode hides and rejects it, and any value other than `0` or `1` fails startup. | Agents on a local server can see a stale index but have no shell to run `gitnexus analyze`. |
| `GITNEXUS_MCP_REFRESH_MAX_FILES` | `5000` | `refresh_index` refuses a repository whose working tree holds more files than this, counted the way analyze scans it before starting the rebuild. `0` removes the limit. | Raise it when larger repositories should be refreshable over MCP. |

View file

@ -1,7 +1,7 @@
# Language Ingestion Gap Inventories
These inventories are the starting point for language-specific plans. There is one
`<language>-ingestion-gaps.md` file for each of the 18 languages in
`<language>-ingestion-gaps.md` file for each of the 19 languages in
[SCOPE_RESOLVERS](../../gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts).
Names match provider directories; `jvm` is shared infrastructure, not another language.
@ -27,6 +27,7 @@ receiver examples do not establish whole-language, CFG/PDG or rename completenes
| [JavaScript](javascript-ingestion-gaps.md) | Preserve JSDoc container evidence for indexed receivers. |
| [Kotlin](kotlin-ingestion-gaps.md) | Inventory companion and inherited-receiver limits. |
| [Objective-C](objective-c-ingestion-gaps.md) | Add receiver measurements without weakening dynamic-dispatch boundaries. |
| [PL/SQL](plsql-ingestion-gaps.md) | Measure call and table-access recall on real Oracle package corpora. |
| [PHP](php-ingestion-gaps.md) | Retain PHPDoc container types and establish namespace owners. |
| [Python](python-ingestion-gaps.md) | Audit constructor-field and module ownership coverage. |
| [Ruby](ruby-ingestion-gaps.md) | Isolate YARD parameter bindings before adding recall. |

View file

@ -0,0 +1,79 @@
# Oracle PL/SQL Ingestion Gaps
Initial inventory for the `experimental` PL/SQL provider. The provider is a
standalone ANTLR4 path (COBOL precedent): `plsql-processor.ts` owns every
structural edge, and the registered resolver runs in
`scopeResolutionEdgeMode: callable-flow-only`, contributing declarations and
the source bindings rename relies on. Nothing below is a whole-language
completeness claim.
## What is modelled
| Construct | Graph | Evidence |
| ------------------------------------------------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| Package spec + body (`.pks`/`.pkb`/`.pck`, or `.sql`) | One `Module`, anchored at the spec, `CONTAINS` from every defining file | `plsql.test.ts` |
| Package procedure/function, nested subprogram, standalone, trigger | `Function`; `isExported` = declared in the spec (package members) | `plsql.test.ts` |
| Calls: local, nested, `pkg.proc`, `schema.pkg.proc` | `CALLS` (`plsql-call`); overloads narrowed by arity and named arguments | `plsql.test.ts` (overload line check) |
| `SELECT`/`INSERT`/`UPDATE`/`DELETE`/`MERGE`/`LOCK`, trigger target | `Record` per table, `ACCESSES` with `reason: plsql-<op>` | `plsql.test.ts`, `plsql-extract.test.ts` |
| SQL*Plus `@@script`, `@script`, `START script` | `IMPORTS` File → File (exact paths only) | `plsql.test.ts`, `external-import-conformance.test.ts` |
| Rename of a package or subprogram | Spec, body, `END` label and qualified call sites together | `rename-integration/plsql/` |
Deliberately no edge: built-ins (`TRUNC`, `DBMS_OUTPUT.PUT_LINE`) and any call
that does not resolve to a definition in the repository; calls over a database
link; CTE names; `DUAL`; scripts that are not at an exact repository path.
## Parser robustness
Grammar: grammars-v4 `sql/plsql` (Apache-2.0) generated with antlr-ng for
antlr4ng; see `gitnexus/src/core/ingestion/plsql/generated/README.md`.
- A line-preserving pre-pass blanks SQL*Plus client commands (only between
statements), neutralises `&` substitution variables at equal length and
resolves conditional compilation by keeping the first branch of every
`$IF` (nesting-aware). Token positions remain original source positions.
- Files are split into `/`-terminated segments; each is parsed SLL-first,
falls back to LL with error recovery, and has a wall-clock deadline.
- A segment with syntax errors keeps whatever the recovered tree yields and
gains regex-level definitions for any subprogram the tree lost
(`fromFallback`, lower edge confidence, rename refused).
- The parse deadline scales with segment size (10 s floor, 15 ms/line;
`GITNEXUS_PLSQL_SEGMENT_DEADLINE_MS` overrides the floor, 0 disables), so
large valid package bodies are not cut off on a cold DFA cache.
- Sites (calls, accesses) are owned by the innermost enclosing definition by
position, so overloads keep their own edges.
- Same-named packages in different folders stay separate: a body pairs with
the nearest spec, and call ties are broken by path proximity. Rename
families stay within one package instance.
Spike measurement (801 files / 245k lines from utPLSQL, OraOpenSource Logger,
Alexandria PL/SQL utils, OOS-Utils and Oracle db-sample-schemas): 97.6% of
package/procedure/function/trigger files parse without syntax errors; about
175 ms per 1,000 lines once the ANTLR DFA cache is warm, about 650 ms cold.
## Planning candidates
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
| --------- | ----------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| PLSQL-01 | Corpus measurement | Call and table-access recall is proven on fixtures, not measured on large real package bodies. | Hand-label a sample of real packages; report recall/precision per edge kind before promoting from `experimental`. |
| PLSQL-02 | Grammar gaps | `TREAT(x AS t).m()`, newer keywords used as identifiers (`ANNOTATIONS`, `XDB`), some object-type member syntax. | Upstream grammar fixes to grammars-v4; regenerate; re-run the corpus. |
| PLSQL-03 | Ambiguity | An argument-less function call in an expression is indistinguishable from a variable or column. | Resolve against local declarations first; keep rename refusing until then. |
| PLSQL-04 | Dynamic SQL | `EXECUTE IMMEDIATE` sites are recorded but produce no edges; only literal text is kept. | Constant-fold `l_sql := '…'` assignments and parse literal statements as low-confidence edges. |
| PLSQL-05 | Not modelled | Object types (`CREATE TYPE … AS OBJECT`, `UNDER`, member methods), synonyms, views' column lineage. | Decide labels (`Class`/`Method`, `EXTENDS`) and schema impact with maintainers; separate PR. |
| PLSQL-06 | Identity | Tables are keyed by unqualified name; same-named tables in two schemas merge. | Key by `SCHEMA.TABLE` when written and reconcile unqualified references through synonyms / current schema. |
| PLSQL-07 | Performance | The phase runs on the main thread before parse: the corpus above (618 claimed files, 121k lines) takes ~60 s cold in one process; facts are shared with scope extraction within a run but not persisted across runs. | Move segment parsing to workers (one warm ANTLR DFA each); persist extracted facts in the parse cache keyed by content hash so incremental runs skip unchanged files. |
| PLSQL-09 | Overload fan-out | Overloads that differ only by parameter type (e.g. utPLSQL `ut.expect`) cannot be narrowed by arity, so each call gets one 0.5-confidence edge per candidate (3,170 ambiguous sites on the corpus after path-proximity narrowing). | Infer argument types from literals and declared variables/parameters; narrow by declared parameter types. |
| PLSQL-13 | SQL*Plus-only scripts | A `.sql` file holding only SQL*Plus commands (e.g. `EXEC pkg.proc;`), with no Oracle unit or include, is not claimed because `EXEC` is also T-SQL; rename cannot see such a caller. Rename does refuse when a claimed file's SQL*Plus command names the target. | Claim such scripts when they sit in a tree that holds Oracle sources, or parse `EXEC` bodies as call sites. |
| PLSQL-11 | Cross-language rename | Java/Kotlin/XML code that calls procedures by string (`{call pkg.proc(?)}`, MyBatis `CALLABLE`) is not consulted by rename. | Implement the rename interop hooks, or refuse when a non-PL/SQL source mentions the qualified name. |
| PLSQL-12 | Incremental identity | Table `Record` and merged package `Module` nodes are anchored to one file although derived from several. | Add an edit-one-file incremental-analyze test; give synthetic objects a stable anchor if writeback drops them. |
| PLSQL-10 | Impact on tables | `impact` adds `ACCESSES` by default only for `Class`/`Interface` targets, so a table needs `relationTypes: ['ACCESSES', …]` to list its readers and writers. | Agree with maintainers whether `Record` targets should get the same default. |
| PLSQL-08 | CFG/PDG | `collectSourceCfgs` returns no CFGs. | Build statement-level CFGs from the parse tree for `analyze --pdg`. |
## Code and regression entry points
- [Provider](../../gitnexus/src/core/ingestion/languages/plsql.ts) and [hooks](../../gitnexus/src/core/ingestion/languages/plsql/).
- [Parser, pre-pass and extraction](../../gitnexus/src/core/ingestion/plsql/).
- [Processor](../../gitnexus/src/core/ingestion/plsql-processor.ts) and [phase](../../gitnexus/src/core/ingestion/pipeline-phases/plsql.ts).
- [plsql.test.ts](../../gitnexus/test/integration/resolvers/plsql.test.ts),
[plsql-extract.test.ts](../../gitnexus/test/unit/plsql-extract.test.ts),
[plsql-preprocess.test.ts](../../gitnexus/test/unit/plsql-preprocess.test.ts),
[rename fixtures](../../gitnexus/test/fixtures/rename-integration/plsql/).

View file

@ -55,6 +55,8 @@ export default [
'**/.stryker-tmp/**',
'gitnexus/reports/mutation/**',
'gitnexus/vendor/**',
// ANTLR4-generated PL/SQL lexer/parser (regenerate, never hand-edit).
'gitnexus/src/core/ingestion/plsql/generated/**',
'gitnexus-web/src/vendor/**',
'gitnexus/test/fixtures/**',
'gitnexus-web/test/fixtures/**',

View file

@ -55,6 +55,19 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
[SupportedLanguages.Zig]: ['.zig'],
// `.sql` is claimed by content (provider.classifyFileContent), not by extension.
[SupportedLanguages.PlSql]: [
'.pks',
'.pkb',
'.pck',
'.pls',
'.plb',
'.prc',
'.fnc',
'.trg',
'.tps',
'.tpb',
],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
/** Pre-built reverse lookup: extension → language (built once at module load). */
@ -129,6 +142,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Cobol]: 'cobol',
[SupportedLanguages.Zig]: 'zig',
[SupportedLanguages.PlSql]: 'plsql',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
/** Non-code file extensions → Prism-compatible syntax identifiers */

View file

@ -24,4 +24,6 @@ export enum SupportedLanguages {
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
Zig = 'zig',
/** Oracle PL/SQL — standalone ANTLR4 provider (no tree-sitter). */
PlSql = 'plsql',
}

View file

@ -16,6 +16,7 @@
*
* Added after Ring 1: zig enters as `experimental` (new language
* integration; promotion to `production` is a separate governance PR).
* plsql enters as `experimental` (standalone ANTLR4 provider path).
*/
import { SupportedLanguages } from '../languages.js';
@ -47,6 +48,7 @@ export const LanguageClassifications: Readonly<Record<SupportedLanguages, Langua
[SupportedLanguages.Vue]: 'experimental',
[SupportedLanguages.Cobol]: 'experimental',
[SupportedLanguages.Zig]: 'experimental',
[SupportedLanguages.PlSql]: 'experimental',
};
/** Convenience predicate: is this language gating Ring 4 retirement? */

View file

@ -2,6 +2,7 @@
"_what": "Baselines for bench/import-target/measure.mjs cover every import-target resolver registered in SCOPE_RESOLVERS on a shared corpus, plus a configured C# arm for the branch the default call cannot reach. measure.mjs derives its arm inventory from LANG_REGISTRY and --check reconciles the registered languages in both directions. The single PHP arm supplies its production PSR-4 Composer mapping; csharp_csproj supplies csproj configuration. C and C++ also receive their production resolutionConfig header corpus. The timing, shape, fingerprint, context, and retained-heap gates therefore cover each production resolver path without splitting PHP into configured and unconfigured identities.",
"_rebaselined_2960_kotlin_declared_packages": "Kotlin now resolves only from parsed package facts and local module bindings. This deliberately changes its five fingerprints, removes path-depth sensitivity, adds the context probe, and reduces the 32000-file retained index from 40.82 MiB to 4.31 MiB. External same-name path decoys now remain unresolved.",
"_zig_arm_1432": "zig was registered in SCOPE_RESOLVERS by PR #1432 with no arm here, which is the exact hole the inventory arm exists for, and it reported it. The arm passes NO build config: resolveZigImportInternal walks an importer-relative @import path component by component and probes allFiles.has() twice (as spelled, then + '.zig'), so its fingerprints pin that walk alone and do not move when build.zig / build.zig.zon parsing (the bare-name legs, gated by test/unit/zig-import-resolver.test.ts) changes. Same corpus proportions as rust — 979 of 3200 resolve at 400 files — and rust's collide design, a deep tree whose spellings carry thirteen components against the unique arm's three, because component count is the only axis the cost has. Measured on one box: small 1.036 ms, collide 1.700 ms, scaling 1.050, collide scaling 1.011, depth 1.573; budgets take the file's usual ~1.5x on ratios (2.4 depth) and ~4x on absolute ms (4 / 7). Heap reads 16 B at both 8000 and 32000 files — it builds nothing, exactly rust's reading — so it sits in heap_bound_bytes at rust's absolute 1048576 B.",
"_plsql_arm_3772": "plsql was registered in SCOPE_RESOLVERS by PR #3772. Its import sites are SQL*Plus script includes: resolvePlSqlScriptInclude probes allFilePaths.has() on at most four exact candidates (repository root, then the calling script's directory; as spelled, then + '.sql') and never searches, so it takes zig's corpus proportions and collide design (a deep tree, thirteen components against three). ORACLE_HOME scripts (?/rdbms/admin/...) are rejected before any probe. Measured on one box: small 1.379 ms, collide 2.354 ms, scaling 1.015, collide scaling 1.018, depth 1.687; budgets take the file's usual ~1.5x on ratios (2.6 depth) and ~4x on absolute ms (6 / 10). Heap reads 16 B at both 8000 and 32000 files - it builds nothing - so it sits in heap_bound_bytes at zig's absolute 1048576 B.",
"_objc_arm_3179": "Objective-C resolves local header and source imports through a cached suffix map. The map preserves the previous first-path tie-break, which is covered directly in objective-c-provider.test.ts. This arm covers the registered resolver's five dispatch paths, including the depth-sensitive index construction and a collision layout. Measured on this branch: small 1.844 ms, collide 1.861 ms, scaling 1.111, collide scaling 1.067, depth 2.779, and 44101944 retained bytes. The 3.6 depth budget allows normal timing variation while remaining below a full per-import file scan; collision scaling stays at the shared linear 1.8 budget because suffix lookup is keyed.",
"_cobol_copybook_dir_preference_2967": "COBOL now prefers well-known copybook directories (copybooks/, cpy/, copy/, plus importer dir) over vendor paths when resolving COPY statements, so COPY EXTERNAL no longer hits same-named decoys outside the project structure. This intentional behavior change moves the resolver from depth-free (prior measured ~0.885) to depth-sensitive (measured 1.751 on CI), because the new preferredCopybookDirs check walks path components. The 2.4 depth budget is ~1.37x the measured ratio, consistent with the ~1.5x convention for depth arms. The unique-arm fingerprints and resolved counts change to reflect the new target set: small/deep drop from 1153 to 442 resolved because files outside preferred directories are now correctly filtered. The collide arm (svc${d}/copybooks layout) keeps 1153 resolved because ALL its files are in preferred copybook directories, so the filter does not change its resolution outcomes — the collide arm now measures collision behavior within the preferred class rather than across mixed layouts. Fixes #2967.",
"_php_composer_gate_2962": "The canonical PHP arm supplies an authoritative App PSR-4 mapping. A deterministic Vendor0 suffix decoy makes deletion of the external gate change every timing fingerprint, while the heap arm separately pins a mapped miss, the rendered mapping, and the external null result. Three serial samples measured depth ratios 1.058, 1.114, and 1.158; the 1.8 budget is 1.55x the observed maximum. The mapped-miss heap reading peaked at 39607216 retained bytes at 32000 files.",
@ -31,7 +32,8 @@
"c": 3.8,
"cpp": 4,
"objc": 1.8,
"zig": 1.8
"zig": 1.8,
"plsql": 1.8
},
"depth_budget": {
"go": 1.4,
@ -52,7 +54,8 @@
"c": 3,
"cpp": 3,
"objc": 3.6,
"zig": 2.4
"zig": 2.4,
"plsql": 2.6
},
"small_ms_ceiling": {
"go": 7,
@ -73,7 +76,8 @@
"c": 7,
"cpp": 7,
"objc": 8,
"zig": 4
"zig": 4,
"plsql": 6
},
"collide_ms_ceiling": {
"go": 28,
@ -94,7 +98,8 @@
"c": 11,
"cpp": 12,
"objc": 8,
"zig": 7
"zig": 7,
"plsql": 10
},
"heap_ceiling_bytes": {
"rust": 31300000,
@ -135,7 +140,8 @@
"javascript": 1048576,
"typescript": 1048576,
"vue": 1048576,
"zig": 1048576
"zig": 1048576,
"plsql": 1048576
},
"heap_floor_fraction": 0.5,
"heap_ratio_budget": 1.25,
@ -1162,6 +1168,57 @@
"scaling_ratio": 1.05,
"small_ms": 1.036
}
},
"plsql": {
"small": {
"files": 400,
"imports": 3200,
"resolved": 979,
"distinct_outcomes": 2997,
"fingerprint": "dcf564b77271c71148ce5e93d29c3f35a378a6c28a659f8f85cbaa703dcba783"
},
"large": {
"files": 1600,
"imports": 12800,
"resolved": 4064,
"distinct_outcomes": 11934,
"fingerprint": "d1a44689c312e5ebb911cda66ee7a813a308c0e8eecc369248b8f74cf108b93a"
},
"deep": {
"files": 400,
"imports": 3200,
"resolved": 979,
"distinct_outcomes": 2997,
"fingerprint": "0ccb52824580a94f0ee27d5398b0a696059781086375fb9a4bfafc51690a71ec"
},
"collide": {
"files": 400,
"imports": 3200,
"resolved": 979,
"distinct_outcomes": 2997,
"fingerprint": "0211995236c0c561e2d2d87c235cba2a7fab95c6dcdde4689ad67908b9e8bd41"
},
"collide_large": {
"files": 1600,
"imports": 12800,
"resolved": 4064,
"distinct_outcomes": 11934,
"fingerprint": "a9e28b724981fdf0312d90ca9ccc6f070f02dfa1ac7c8552c9febd3cd3dacbdc"
},
"fingerprint": "d1a44689c312e5ebb911cda66ee7a813a308c0e8eecc369248b8f74cf108b93a",
"heap": {
"files_small": 8000,
"files_large": 32000,
"path_segments": 11,
"probe": "vendor0/missing"
},
"_measured": {
"collide_ms": 2.354,
"collide_scaling_ratio": 1.018,
"depth_ratio": 1.687,
"scaling_ratio": 1.015,
"small_ms": 1.379
}
}
},
"_blind_spot": "MEASURED, so nobody has to rediscover it: a full workspace scan reintroduced on 1-in-32 imports passes EVERY arm here \u2014 dart scored 1.458 scaling and 1.736 ms against the 1.8 budget and 4 ms ceiling of an earlier revision. At 1-in-8 the scaling arm catches it (2.414). The gate that NARROWS this is not a timing gate at all: test/unit/scope-resolution/import-target-index-parity.test.ts counts iterations of the file-set Set and reads 14 instead of 1 for that same 1-in-32 mutation, deterministically and for all five languages. It does NOT close it. The counter watches the Set, and the resolvers no longer read the Set \u2014 they read materialized copies of the same file list: WorkspaceFileIndex.normalized and .all (C#, Ruby), Dart's byBasename buckets, and PackageDirIndex.filesByDir (Go, C#). A 1-in-32 scan over any of those three touches the Set zero extra times, so it passes the parity test AND passes --check. Closing it would take an iteration counter on the materialized arrays themselves. Read the two gates together; tightening these ceilings toward the noise floor to chase that case would only buy flaky CI. CONFIRMED THE HARD WAY by PR #2911: JavaScript resolution was scanning ImportPassCache.normalizedFileList on every import \u2014 a materialized array, not the Set \u2014 at 25972 us per import at 8000 files, and no instrument on the #2901-#2909 branch could see it. It took a differential parity test over 211200 old-vs-new pairs to find. The arms added here would have caught THAT one on absolute ms (85 ms budget against a 20 ms arm; the unindexed resolver costs ~83000 ms on the same corpus), which is the argument for gating every registered language rather than only the ones a PR happens to touch. THE SECOND BLIND SPOT IS CLOSED, and this records what closing it changed. This harness used to call the inner resolvers with the NO-CONTEXT shape: run.ts calls provider.resolveImportTarget with five arguments, the fifth being { parsedFiles, parsedImport }, and resolveOne supplied three. resolveOne now makes the production call, newPass mints the ParsedFile[] FIRST and derives the path set from it exactly as run.ts does, and both legs behind the argument run on every import of their arms \u2014 PHP's named/alias function-or-const leg over filesByDirectory(context.parsedFiles), whose memo defeated measures 197.0 us -> 9976.2 us per import (50.6x), and Python's from-import submodule-precedence branch, the only spelling that reads context.parsedFiles at all. Fifteen of the seventeen arms cannot observe a context (their hooks declare three or four parameters) and are handed none, so their numbers did not move; which two CAN is now reconciled against SCOPE_RESOLVERS' hook arity rather than asserted in prose. NOTHING ELSE IN THIS FILE COULD HAVE GATED IT, which is why the context arm exists: fingerprints and shape can remain unchanged while dropping context only makes timing faster. The deterministic context arm is therefore the guard for this wiring. The arm is one import per language resolved through resolveOne twice, with and without the pass's parsedFiles, whose two answers must DIFFER and must both match what is recorded. WHAT REMAINS UNMEASURED, narrowed rather than deleted: Python's parsedFileByPath memo is exercised by the five timing arms and cannot be reached by the heap arm at all, because retainedPassBytes requires a probe that MISSES while every path that builds that memo returns a non-null packageTarget \u2014 so no ceiling bounds that Map (one pointer per parsed file, O(files), no depth term) and the contract test's count gate is what holds it to one build per pass. PHP's sole arm carries a representative Composer PSR-4 map, so mapped hits and authoritative misses exercise that production branch directly. And the const tail of PHP's leg is a different ANSWER at the same cost \u2014 it runs the identical candidate gather and localDefs filter and diverges in the last two lines \u2014 so it is gated by count in test/unit/scope-resolution/import-target-index-reuse.contract.test.ts, which stays the gate to read alongside this file.",

View file

@ -486,6 +486,7 @@ import { cobolScopeResolver } from '../../src/core/ingestion/languages/cobol/sco
import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts';
import { resolveRustImportTarget } from '../../src/core/ingestion/languages/rust/import-target.ts';
import { resolveZigImportInternal } from '../../src/core/ingestion/languages/zig/import-helpers.ts';
import { resolvePlSqlScriptInclude } from '../../src/core/ingestion/plsql/script-include.ts';
import { resolvePythonImportTarget } from '../../src/core/ingestion/languages/python/import-target.ts';
import { makeJsResolveImportTarget } from '../../src/core/ingestion/languages/javascript/import-target.ts';
import { makeVueResolveImportTarget } from '../../src/core/ingestion/languages/vue/import-target.ts';
@ -788,6 +789,7 @@ const EXTENSION = {
cpp: '.cpp',
objc: '.m',
zig: '.zig',
plsql: '.sql',
};
/** C and C++ resolve `#include` against HEADERS, which reach the resolver
* through `resolutionConfig` rather than through `allFilePaths` — see
@ -920,6 +922,9 @@ function uniqueDir(lang, d, i) {
// share the other unique arms take from a nested directory comes from the
// target instead (see `uniqueTarget`).
if (lang === 'zig') return `src/mod${d}`;
// SQL*Plus install trees: one folder per component under `db/`, so a
// script reaches a sibling through one `..` (see `uniqueTarget`).
if (lang === 'plsql') return `db/mod${d}`;
throw unwiredLanguage('uniqueDir', lang);
}
@ -1015,6 +1020,10 @@ function collideDir(lang, d, i) {
// axis that CAN grow — `collideTarget` spells its imports up through the
// tree and back down, ~4x the components of the unique arm.
if (lang === 'zig') return `src/l0/l1/l2/l3/l4/mod${d}`;
// zig's reasoning: the resolver probes `.has()` on at most four candidates
// (two bases × with/without `.sql`) and never searches, so path depth is
// the only axis its cost can grow along.
if (lang === 'plsql') return `db/l0/l1/l2/l3/l4/mod${d}`;
throw unwiredLanguage('collideDir', lang);
}
@ -1467,6 +1476,25 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
return `../vendor${(r >>> 4) % 97}/missing.zig`;
}
if (lang === 'plsql') {
// SQL*Plus `@file` / `START file`: tried against the repository root, then
// the calling script's directory. Every script sits in `db/mod{d}/`, so
// `../mod{n}/…` misses at the root (it climbs out of the repo) and hits
// from the caller — both bases are walked for every local include. The
// `d % 7` slice names an `inner/` that exists nowhere and misses; one
// spelling in three drops `.sql`, which is the appended-extension leg.
// Misses: ORACLE_HOME scripts (`?/rdbms/admin/…`, rejected before any
// probe), a bare vendor script that is in neither base, and a relative
// path to a vendored script outside the corpus.
if (local) {
if (d % 7 === 0) return `../mod${d}/inner/file${j}.sql`;
return (r >>> 3) % 3 === 0 ? `../mod${j % dirs}/file${j}` : `../mod${j % dirs}/file${j}.sql`;
}
const miss = (r >>> 3) % 3;
if (miss === 0) return ['?/rdbms/admin/utlrp.sql', '?/rdbms/admin/catproc.sql'][(r >>> 4) % 2];
if (miss === 1) return `vendor_patch${(r >>> 4) % 97}`;
return `../vendor${(r >>> 4) % 97}/missing.sql`;
}
throw unwiredLanguage('uniqueTarget', lang);
}
@ -1700,6 +1728,21 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
return `${up}/vendor${(r >>> 4) % 97}/missing.zig`;
}
if (lang === 'plsql') {
// zig's collide design: the same families in the same proportions, spelled
// up to `db/` and back down through `l0/…/l4`.
const up = '../../../../../../l0/l1/l2/l3/l4';
if (local) {
if (d % 7 === 0) return `${up}/mod${d}/inner/file${j}.sql`;
return (r >>> 3) % 3 === 0
? `${up}/mod${j % dirs}/file${j}`
: `${up}/mod${j % dirs}/file${j}.sql`;
}
const miss = (r >>> 3) % 3;
if (miss === 0) return ['?/rdbms/admin/utlrp.sql', '?/rdbms/admin/catproc.sql'][(r >>> 4) % 2];
if (miss === 1) return `vendor_patch${(r >>> 4) % 97}`;
return `${up}/vendor${(r >>> 4) % 97}/missing.sql`;
}
throw unwiredLanguage('collideTarget', lang);
}
@ -1931,6 +1974,7 @@ function resolveOne(lang, from, target, pass) {
// `null` build config: this arm pins the path walk alone
// (see the header); the config legs are gated by their own unit tests.
if (lang === 'zig') return resolveZigImportInternal(from, target, allFilePaths, null);
if (lang === 'plsql') return resolvePlSqlScriptInclude(target, from, allFilePaths);
if (lang === 'python') {
// `from <target> import X` — the spelling the orchestrator actually hands
// the provider, and the ONLY one that reads `context.parsedFiles`: a
@ -2243,6 +2287,10 @@ const HEAP_PROBE_TARGET = {
// miss after the full component walk, which is the longest leg the resolver
// has (a by-name miss returns before any walk).
zig: '../vendor0/missing.zig',
// No extension and no `..`: the repository root and the caller's directory
// are each probed with `.sql` appended and as written — four misses, the
// longest leg the resolver has.
plsql: 'vendor0/missing',
typescript: 'vendor0/lib/missing',
vue: 'vendor0/lib/Missing.vue',
cpp: 'vendor0/missing.hpp',
@ -2662,6 +2710,7 @@ const LANG_REGISTRY = {
cpp: SupportedLanguages.CPlusPlus,
objc: SupportedLanguages.ObjectiveC,
zig: SupportedLanguages.Zig,
plsql: SupportedLanguages.PlSql,
};
const LANGS = Object.keys(LANG_REGISTRY);
/**

View file

@ -0,0 +1,38 @@
# PL/SQL parse-throughput bench
`measure.mjs` times `extractPlSqlFacts` (SQL\*Plus/conditional-compilation
pre-pass, `/` segment split, ANTLR SLL→LL parse, fact extraction) on a
synthetic package body of 60 and 240 subprograms, after one warm-up pass.
```sh
node --import tsx bench/plsql-parse/measure.mjs # print JSON
node --import tsx bench/plsql-parse/measure.mjs --check # CI guard
```
`--check` gates correctness and shape, not absolute speed:
- **fingerprint**: sha256 over the facts of every `lang-resolution/plsql-*`
fixture file plus a 20-subprogram synthetic body. Re-baseline
`baselines.json` only for an intended facts change.
- **synthetic bodies** parse with no erroneous or timed-out segment.
- **scaling ratio** `(t_240 / t_60) / (lines ratio)` stays under 1.5. About
1.0 is linear; a quadratic regression shows up near 4.0.
## Measurements
Apple Silicon laptop, Node 25, single thread.
| Input | Lines | Result |
| ---------------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------- |
| Synthetic body, 60 subprograms, cold (first parse in the process) | 783 | 677 ms |
| Synthetic body, 60 subprograms, warm | 783 | 125 ms/kLOC |
| Synthetic body, 240 subprograms, warm | 3,123 | 116 ms/kLOC (scaling ratio 0.93) |
| Corpus: utPLSQL, Logger, Alexandria, OOS-Utils, Oracle sample schemas (618 claimed files, one process, one cold start) | 121,292 | 58–62 s end to end through `processPlSql` (about 490 ms/kLOC), 0 segments at the deadline |
The corpus is not vendored; the processor-level number is a manual
measurement to compare against. It roughly doubles on a machine under memory
pressure, so compare runs taken back to back. Real code costs more per line than the
synthetic body: deeper expressions, more SQL and more LL fallbacks.
Parsing runs on the main thread inside the `plsql` phase and facts are not
cached across runs (PLSQL-07 in `docs/languages/plsql-ingestion-gaps.md`).

View file

@ -0,0 +1,5 @@
{
"_comment": "Baseline for bench/plsql-parse/measure.mjs --check. fingerprint = order-independent sha256 over extractPlSqlFacts output for every lang-resolution/plsql-* fixture file plus a 20-subprogram synthetic package body (correctness gate; re-baseline intentionally on a legitimate facts change). scaling_budget = max allowed (t_large/t_small)/(lines ratio) for 60 vs 240 synthetic subprograms after a DFA warm-up; ~1.0 is linear (0.95 measured), ~4.0 quadratic.",
"fingerprint": "0ccc2c62f8747bb91a8fb34dc73b1335600a629ce92160ae539b851f4e1d4637",
"scaling_budget": 1.5
}

View file

@ -0,0 +1,173 @@
/**
* Build-free PL/SQL parse-throughput harness (#3771 acceptance criterion).
*
* Times `extractPlSqlFacts` — pre-pass, segment split, ANTLR SLL/LL parse and
* fact extraction — on a synthetic package body at two sizes, after one
* warm-up pass so the ANTLR DFA cache is populated (each analyze pays the
* cold cost once per process; see README.md for corpus numbers). It reports
* ms/kLOC for each size and a scaling ratio `(t_large/t_small)/(lines ratio)`:
* ~1.0 is linear, ~4.0 is quadratic at these sizes.
*
* The correctness gate is an order-independent sha256 fingerprint over the
* facts of every `lang-resolution/plsql-*` fixture file plus a fixed
* 20-subprogram synthetic body.
*
* Run: `node --import tsx bench/plsql-parse/measure.mjs` (prints JSON).
* `--check` asserts the fingerprint equals baselines.json, the synthetic
* bodies parse without syntax errors or timeouts, and the scaling ratio stays
* under budget; exits non-zero otherwise. Absolute timings are reported, not
* gated: they depend on the machine.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { extractPlSqlFacts } from '../../src/core/ingestion/plsql/extract.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const PLSQL_EXTENSIONS = ['.sql', '.pks', '.pkb', '.pck', '.pls', '.plb', '.prc', '.fnc', '.trg'];
/**
* The production, size-scaled segment deadline: a parse that stalls ends as a
* timed-out segment, which `--check` rejects, instead of hanging the job.
*/
const OPTIONS = {};
const SMALL = 60;
const LARGE = 240;
const REPEATS = 3;
/** One package body with `n` subprograms that call, branch, read and write. */
function syntheticBody(n) {
const units = [];
for (let i = 0; i < n; i++) {
const callee = i === 0 ? 'NULL' : `p${i - 1}(p_id => p_id, p_amount => v_total)`;
units.push(
[
` PROCEDURE p${i}(p_id IN NUMBER, p_amount IN NUMBER DEFAULT 0) IS`,
` v_total t_orders.amount%TYPE;`,
` BEGIN`,
` SELECT NVL(SUM(amount), 0) INTO v_total FROM t_orders WHERE customer_id = p_id;`,
` IF v_total > p_amount THEN`,
` UPDATE t_orders SET status = 'HELD' WHERE customer_id = p_id;`,
` ELSIF v_total = 0 THEN`,
` INSERT INTO t_audit (customer_id, note) VALUES (p_id, 'empty ${i}');`,
` END IF;`,
` ${callee};`,
` pkg_log.info('p${i} done');`,
` END p${i};`,
].join('\n'),
);
}
return `CREATE OR REPLACE PACKAGE BODY pkg_bench AS\n${units.join('\n\n')}\nEND pkg_bench;\n/\n`;
}
function lineCount(text) {
return text.split('\n').length;
}
function digest(facts) {
const { firstError: _firstError, ...stable } = facts;
return crypto.createHash('sha256').update(JSON.stringify(stable)).digest('hex');
}
function collectFixtures() {
const out = [];
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('plsql-')) continue;
const stack = [path.join(FIXTURE_ROOT, entry.name)];
while (stack.length) {
const dir = stack.pop();
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, c.name);
if (c.isDirectory()) stack.push(p);
else if (PLSQL_EXTENSIONS.some((e) => c.name.toLowerCase().endsWith(e)))
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
}
}
}
return out.sort((a, b) => a.key.localeCompare(b.key));
}
function fingerprint() {
const parts = collectFixtures().map(
(f) => `${f.key}|${digest(extractPlSqlFacts(fs.readFileSync(f.absPath, 'utf8'), OPTIONS))}`,
);
parts.push(`synthetic-20|${digest(extractPlSqlFacts(syntheticBody(20), OPTIONS))}`);
return crypto.createHash('sha256').update(parts.sort().join('\n')).digest('hex');
}
function timeExtract(source) {
let best = Number.POSITIVE_INFINITY;
let facts;
for (let i = 0; i < REPEATS; i++) {
const t0 = performance.now();
facts = extractPlSqlFacts(source, OPTIONS);
best = Math.min(best, performance.now() - t0);
}
return { ms: best, facts };
}
function measure() {
const small = syntheticBody(SMALL);
const large = syntheticBody(LARGE);
const coldStart = performance.now();
extractPlSqlFacts(small, OPTIONS);
const coldMs = performance.now() - coldStart;
const s = timeExtract(small);
const l = timeExtract(large);
const smallLines = lineCount(small);
const largeLines = lineCount(large);
const perKloc = (ms, lines) => +((ms * 1000) / lines).toFixed(1);
return {
fingerprint: fingerprint(),
cold_ms_small: +coldMs.toFixed(1),
small: {
subprograms: SMALL,
lines: smallLines,
ms: +s.ms.toFixed(1),
ms_per_kloc: perKloc(s.ms, smallLines),
},
large: {
subprograms: LARGE,
lines: largeLines,
ms: +l.ms.toFixed(1),
ms_per_kloc: perKloc(l.ms, largeLines),
},
scaling_ratio: +(l.ms / s.ms / (largeLines / smallLines)).toFixed(3),
synthetic_errors: s.facts.segmentsWithErrors + l.facts.segmentsWithErrors,
synthetic_timeouts: s.facts.segmentsTimedOut + l.facts.segmentsTimedOut,
synthetic_calls: l.facts.calls.length,
synthetic_accesses: l.facts.accesses.length,
};
}
const result = measure();
console.log(JSON.stringify(result, null, 2));
if (process.argv.includes('--check')) {
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const failures = [];
if (result.fingerprint !== baseline.fingerprint)
failures.push(
`fingerprint drift: ${result.fingerprint} != baseline ${baseline.fingerprint} (re-baseline only for an intended facts change)`,
);
if (result.synthetic_timeouts !== 0)
failures.push(
`synthetic bodies hit the parse deadline in ${result.synthetic_timeouts} segments`,
);
if (result.synthetic_errors !== 0)
failures.push(`synthetic bodies parsed with ${result.synthetic_errors} erroneous segments`);
if (result.scaling_ratio >= baseline.scaling_budget)
failures.push(
`scaling ratio ${result.scaling_ratio} >= budget ${baseline.scaling_budget} (super-linear parse)`,
);
if (failures.length > 0) {
for (const f of failures) console.error(`plsql-parse: ${f}`);
process.exit(1);
}
console.error('plsql-parse: fingerprint and scaling within baseline');
}

View file

@ -13,12 +13,12 @@ plus callers and helpers. Rust also needs a root
module file; Objective-C has a header and implementation per entity. Java adds an
isolated producer file and exercises transitive factory returns and repeated
unreachable return-name lookups. Swift uses same-module visibility; COBOL exercises
program references and procedure pointers. Other arms import the preceding file and call through its
program references and procedure pointers; PL/SQL exercises package-qualified calls. Other arms import the preceding file and call through its
return value. These generated sources exercise the analyzer; they are not builds
or representative application benchmarks.
Every sample runs in a fresh process through real parsing with one worker and the
production scope-resolution pass. COBOL also runs its standalone structural parser.
production scope-resolution pass. COBOL and PL/SQL also run their standalone structural parsers.
Configuration files are written to a temporary workspace and loaded through the resolver's normal
configuration hook. No production heap, file-count, or propagation limit is imposed.

View file

@ -175,6 +175,22 @@
"moduleTypeBindings": 10010999,
"typeBindings": 10032997
},
"plsql/100/20": {
"fingerprint": "908f30ebfe4287bbb4d4553a869da52708dbf92b0e99548dd58d73f8664bdf3b",
"nodes": 2300,
"edges": 2398,
"crossFileCalls": 198,
"moduleTypeBindings": 0,
"typeBindings": 0
},
"plsql/1000/20": {
"fingerprint": "600a7dc54fdf793e40b66c47551215ad5cb4aa3af9d432c283750fd882224dd8",
"nodes": 23000,
"edges": 23998,
"crossFileCalls": 1998,
"moduleTypeBindings": 0,
"typeBindings": 0
},
"python/100/20": {
"fingerprint": "58adb2bc6b7ac24ab3dcd500ec3cd577d3e6853fd1a8e84f658f423677d6df50",
"nodes": 2299,

View file

@ -18,7 +18,8 @@ ${previous(i, (p) => ` usePrevious() { return new C${p}().make${p}_0().make${p}
}`;
// Each arm has one chain of cross-file uses and distinct method names. Swift
// uses same-module visibility; COBOL uses program calls and procedure pointers.
// uses same-module visibility; COBOL uses program calls and procedure pointers;
// PL/SQL uses package-qualified calls.
export const generators = {
javascript: (i, n) => ({ path: `src/C${i}.js`, content: script(i, n, false, 'js') }),
typescript: (i, n) => ({ path: `src/C${i}.ts`, content: script(i, n, true, 'js') }),
@ -175,6 +176,24 @@ ${previous(i, (p) => ` SET TARGET-PTR TO ENTRY 'C${p}'\n CAL
PERFORM MAKE-${i}-0
GOBACK.
${members(n, (j) => ` MAKE-${i}-${j}.\n CONTINUE.`)}
`,
}),
plsql: (i, n) => ({
path: `src/pkg_c${i}.pck`,
content: `CREATE OR REPLACE PACKAGE pkg_c${i} AS
${members(n, (j) => ` PROCEDURE make${i}_${j};`)}
PROCEDURE use_previous;
END pkg_c${i};
/
CREATE OR REPLACE PACKAGE BODY pkg_c${i} AS
${members(n, (j) => ` PROCEDURE make${i}_${j} IS BEGIN NULL; END make${i}_${j};`)}
PROCEDURE use_previous IS
BEGIN
${previous(i, (p) => ` pkg_c${p}.make${p}_0;\n pkg_c${p}.make${p}_1;`)}
NULL;
END use_previous;
END pkg_c${i};
/
`,
}),
zig: (i, n) => ({

View file

@ -18,6 +18,8 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js';
import { runScopeResolution } from '../../src/core/ingestion/scope-resolution/pipeline/run.js';
import { processCobol } from '../../src/core/ingestion/cobol-processor.js';
import { processPlSql } from '../../src/core/ingestion/plsql-processor.js';
import { loadPlSqlParser } from '../../src/core/ingestion/plsql/facts-store.js';
import { generateId } from '../../src/lib/utils.js';
import { parseFilesWithWorkers } from '../../test/helpers/worker-parse.js';
import { corpus, generators } from './corpus.mjs';
@ -54,6 +56,11 @@ async function sample(language: SupportedLanguages, count: number, methods: numb
if (language === SupportedLanguages.Cobol) {
processCobol(graph, files, new Set(files.map((file) => file.path)));
}
// PL/SQL's structural graph is emitted by its standalone ANTLR parser, loaded on demand.
if (language === SupportedLanguages.PlSql) {
await loadPlSqlParser();
processPlSql(graph, files, new Set(files.map((file) => file.path)));
}
const parseMs = performance.now() - parseStarted;
const heapAfterParse = process.memoryUsage().heapUsed;
let parsedFileCount = 0;

View file

@ -26,6 +26,7 @@
"@ladybugdb/core": "0.22.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"antlr4ng": "3.0.16",
"busboy": "^1.6.0",
"chokidar": "^5.0.0",
"cli-progress": "^3.12.0",
@ -3073,6 +3074,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/antlr4ng": {
"version": "3.0.16",
"resolved": "https://registry.npmjs.org/antlr4ng/-/antlr4ng-3.0.16.tgz",
"integrity": "sha512-DQuJkC7kX3xunfF4K2KsWTSvoxxslv+FQp/WHQZTJSsH2Ec3QfFmrxC3Nky2ok9yglXn6nHM4zUaVDxcN5f6kA==",
"license": "BSD-3-Clause"
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",

View file

@ -84,6 +84,7 @@
"@ladybugdb/core": "0.22.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"antlr4ng": "3.0.16",
"busboy": "^1.6.0",
"chokidar": "^5.0.0",
"cli-progress": "^3.12.0",

View file

@ -186,6 +186,7 @@ const LBUG_NATIVE = [
'test/integration/rename-provider-kotlin.test.ts',
'test/integration/rename-provider-objective-c.test.ts',
'test/integration/rename-provider-php.test.ts',
'test/integration/rename-provider-plsql.test.ts',
'test/integration/rename-provider-python.test.ts',
'test/integration/rename-provider-ruby.test.ts',
'test/integration/rename-provider-rust.test.ts',

View file

@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Regenerate the ANTLR4 PL/SQL lexer/parser under
* src/core/ingestion/plsql/generated/ from the vendored grammars-v4 sources in
* src/core/ingestion/plsql/grammar/.
*
* npx --yes antlr-ng@1.0.10 is fetched on demand; it is not a runtime dependency.
*
* The grammar's C++ header placeholders are replaced with the antlr4ng base-class
* imports, exactly as grammars-v4's `Antlr4ng/transformGrammar.py` does.
*/
import { execSync } from 'node:child_process';
import {
mkdtempSync,
readFileSync,
writeFileSync,
rmSync,
readdirSync,
copyFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const grammarDir = path.join(root, 'src/core/ingestion/plsql/grammar');
const outDir = path.join(root, 'src/core/ingestion/plsql/generated');
const work = mkdtempSync(path.join(tmpdir(), 'plsql-grammar-'));
try {
for (const name of ['PlSqlLexer.g4', 'PlSqlParser.g4']) {
const text = readFileSync(path.join(grammarDir, name), 'utf8')
.replace(
'// Insert here @header for C++ lexer.',
'@header {import { PlSqlLexerBase } from "./PlSqlLexerBase.js"}',
)
.replace(
'// Insert here @header for C++ parser.',
'@header {import { PlSqlParserBase } from "./PlSqlParserBase.js"}',
);
writeFileSync(path.join(work, name), text);
}
// String form runs through the platform shell (cmd.exe / sh) so `npx`
// resolves to npx.cmd on Windows — execFileSync('npx') without a shell
// fails there (same constraint as scripts/build-web.js).
const quote = (arg) => `"${arg}"`;
execSync(
[
'npx --yes antlr-ng@1.0.10 -Dlanguage=TypeScript -l true -v false',
`-o ${quote(work)}`,
quote(path.join(work, 'PlSqlLexer.g4')),
quote(path.join(work, 'PlSqlParser.g4')),
].join(' '),
{ stdio: 'inherit' },
);
for (const file of readdirSync(work)) {
if (/^PlSql(Lexer|Parser|ParserListener)\.ts$/.test(file))
copyFileSync(path.join(work, file), path.join(outDir, file));
}
console.log(`[plsql] regenerated parser into ${path.relative(root, outDir)}`);
} finally {
rmSync(work, { recursive: true, force: true });
}

View file

@ -31,6 +31,7 @@ import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { cobolProvider } from './cobol.js';
import { zigProvider } from './zig.js';
import { plsqlProvider } from './plsql.js';
export const providers = {
[SupportedLanguages.JavaScript]: javascriptProvider,
@ -51,6 +52,7 @@ export const providers = {
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.Cobol]: cobolProvider,
[SupportedLanguages.Zig]: zigProvider,
[SupportedLanguages.PlSql]: plsqlProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;
/** Get provider by language enum (always succeeds for SupportedLanguages). */

View file

@ -0,0 +1,50 @@
/**
* Oracle PL/SQL Language Provider
*
* Standalone ANTLR4 provider — no tree-sitter grammar. PL/SQL files are
* parsed with the grammars-v4 PlSql grammar (`../plsql/`) and mapped to the
* graph by `plsql-processor.ts` in the `plsql` pipeline phase, before parse.
*
* Dedicated extensions (.pks, .pkb, .pck, …) are always PL/SQL. `.sql` is
* claimed through `classifyFileContent` only when the file holds an Oracle
* PL/SQL unit or is a SQL*Plus script that includes other scripts
* (`@@file`, `@file.ext`, `START file.ext`), leaving other SQL dialects alone.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { PLSQL_EXTENSIONS, isSqlFile, looksLikeOraclePlSql } from '../plsql/file-types.js';
import {
collectPlSqlSourceCfgs,
emitPlSqlScopeCaptures,
interpretPlSqlImport,
plsqlImportOwningScope,
plsqlReceiverBinding,
plsqlRenameProvider,
} from './plsql/index.js';
export const plsqlProvider = defineLanguage({
id: SupportedLanguages.PlSql,
parseStrategy: 'standalone',
extensions: [...PLSQL_EXTENSIONS],
// Check the extension first: classifiers run before extension lookup for every file.
shouldClassifyFileContent: isSqlFile,
classifyFileContent: (filePath, sourceText) =>
isSqlFile(filePath) && looksLikeOraclePlSql(sourceText),
entryPointPatterns: [],
astFrameworkPatterns: [],
treeSitterQueries: '',
typeConfig: {
declarationNodeTypes: new Set(),
extractDeclaration: () => null,
extractParameter: () => null,
},
exportChecker: () => false,
rename: plsqlRenameProvider,
collectSourceCfgs: collectPlSqlSourceCfgs,
// ── Scope-resolution hooks ───────────────────────────────────────
emitScopeCaptures: emitPlSqlScopeCaptures,
interpretImport: interpretPlSqlImport,
importOwningScope: plsqlImportOwningScope,
receiverBinding: plsqlReceiverBinding,
});

View file

@ -0,0 +1,146 @@
/**
* `emitScopeCaptures` for Oracle PL/SQL.
*
* Wraps the ANTLR fact extractor (`../../plsql/extract.ts`) and emits the
* parser-agnostic RFC §5.1 vocabulary consumed by the central ScopeExtractor:
*
* package → @scope.module + @declaration.program (→ Module)
* procedure/function → @scope.function + @declaration.function (→ Function)
* trigger → @scope.function + @declaration.function
* spec declaration → @declaration.function (no scope: it has no body)
* call (last part) → @reference.call (+ @reference.arity)
* call qualifier part → @reference.read (the package name in `pkg.proc`)
* END <label> → @reference.read (bound to the definition it closes)
* @@script / START → @import.statement / @import.name
*
* Every name capture uses the exact written token range, so rename and the
* source-binding pass can join captures back to source tokens by range.
*/
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
import type { PlSqlDefinition, PlSqlFileFacts, PlSqlNameToken } from '../../plsql/extract.js';
import { plsqlFactsForFile } from '../../plsql/facts-store.js';
function capture(name: string, range: Range, text: string): Capture {
return { name, range, text };
}
export function plsqlTokenRange(token: PlSqlNameToken): Range {
return {
startLine: token.line,
startCol: token.column,
endLine: token.line,
endCol: token.column + token.length,
};
}
export function plsqlNameRange(def: PlSqlDefinition): Range {
return {
startLine: def.nameLine,
startCol: def.nameColumn,
endLine: def.nameLine,
endCol: def.nameColumn + def.nameLength,
};
}
export function plsqlDefinitionRange(def: PlSqlDefinition): Range {
return {
startLine: def.startLine,
startCol: def.startColumn,
endLine: def.endLine,
endCol: Math.max(def.endColumn, def.startLine === def.endLine ? def.startColumn + 1 : 0),
};
}
export const plsqlRangeKey = (r: Range): string =>
`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
/** Facts for a source, shared with the `plsql` phase (parsed once per run). */
export function plsqlFactsFor(sourceText: string, filePath: string): PlSqlFileFacts {
return plsqlFactsForFile(filePath, sourceText);
}
export function emitPlSqlScopeCaptures(
sourceText: string,
filePath: string,
_cachedTree?: unknown,
): readonly CaptureMatch[] {
const facts = plsqlFactsFor(sourceText, filePath);
const out: CaptureMatch[] = [];
for (const def of facts.definitions) {
const range = plsqlDefinitionRange(def);
const nameRange = plsqlNameRange(def);
const tag = def.kind === 'package' ? '@declaration.program' : '@declaration.function';
const required = def.parameters.filter((p) => !p.hasDefault).length;
const match: Record<string, Capture> = {
[tag]: capture(tag, range, def.name),
'@declaration.name': capture('@declaration.name', nameRange, def.name),
'@declaration.qualified_name': capture(
'@declaration.qualified_name',
nameRange,
[...def.ownerPath, def.name].join('.'),
),
};
if (def.kind !== 'package' && !def.fromFallback) {
match['@declaration.parameter-count'] = capture(
'@declaration.parameter-count',
nameRange,
String(def.parameters.length),
);
match['@declaration.required-parameter-count'] = capture(
'@declaration.required-parameter-count',
nameRange,
String(required),
);
}
// A spec or forward declaration has no body, so it opens no scope.
if (!def.declarationOnly) {
const scope = def.kind === 'package' ? '@scope.module' : '@scope.function';
match[scope] = capture(scope, range, def.name);
}
out.push(match);
for (const label of def.endLabels) {
const r = plsqlTokenRange(label);
out.push({
'@reference.read': capture('@reference.read', r, label.name),
'@reference.name': capture('@reference.name', r, label.name),
});
}
}
for (const call of facts.calls) {
call.nameTokens.forEach((token, i) => {
const r = plsqlTokenRange(token);
const isCallee = i === call.nameTokens.length - 1;
const tag = isCallee ? '@reference.call' : '@reference.read';
out.push({
[tag]: capture(tag, r, token.name),
'@reference.name': capture('@reference.name', r, token.name),
...(isCallee && call.arity !== undefined
? { '@reference.arity': capture('@reference.arity', r, String(call.arity)) }
: {}),
});
});
}
for (const include of facts.scriptIncludes) {
const r: Range = {
startLine: include.line,
startCol: include.column,
endLine: include.line,
endCol: include.column + include.target.length,
};
out.push({
'@import.statement': capture('@import.statement', r, include.target),
// `@@` marks a script-relative include for resolveImportTarget.
'@import.name': capture(
'@import.name',
r,
include.relativeToScript ? `@@${include.target}` : include.target,
),
});
}
return out;
}

View file

@ -0,0 +1,15 @@
/**
* Control-flow graphs for PL/SQL are not built yet. The provider still
* implements `collectSourceCfgs` so the PDG layer treats PL/SQL as a known
* language that contributes no CFGs, rather than an unhandled one.
*/
import type { CollectedCfgs } from '../../cfg/collect.js';
export function collectPlSqlSourceCfgs(
_sourceText: string,
_filePath: string,
_maxFunctionLines: number,
): CollectedCfgs {
return { cfgs: [], skipped: { tooManyLines: 0, tooDeeplyNested: 0, buildError: 0 } };
}

View file

@ -0,0 +1,13 @@
/**
* Oracle PL/SQL scope-resolution public API barrel.
*
* - `captures.ts` — `emitPlSqlScopeCaptures` (wraps the ANTLR fact extractor)
* - `interpret.ts` — import / owning-scope / receiver hooks
* - `rename.ts` — rename provider
* - `flow.ts` — CFG hook (none yet)
*/
export { emitPlSqlScopeCaptures } from './captures.js';
export { interpretPlSqlImport, plsqlImportOwningScope, plsqlReceiverBinding } from './interpret.js';
export { plsqlRenameProvider } from './rename.js';
export { collectPlSqlSourceCfgs } from './flow.js';

View file

@ -0,0 +1,44 @@
/**
* PL/SQL scope-resolution interpret hooks.
*
* PL/SQL itself has no import statement — schema objects are global to the
* database. The one textual inclusion in PL/SQL source trees is SQL*Plus
* `@@script` / `@script` / `START script`, which install scripts use to load
* package specs and bodies in order. It is modelled as a `'named'` import of
* the script path.
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
export function interpretPlSqlImport(match: CaptureMatch): ParsedImport | null {
const name = match['@import.name']?.text;
if (!name) return null;
const bare = name.replace(/^@@/, '');
return { kind: 'named', localName: bare, importedName: bare, targetRaw: name };
}
/** Script inclusion is file-level: it binds in the file's Module scope. */
export function plsqlImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (innermost.kind === 'Module') return innermost.id;
for (const id of tree.getAncestors(innermost.id)) {
const scope = tree.getScope(id);
if (scope?.kind === 'Module') return id;
}
return null;
}
/** PL/SQL subprograms have no implicit receiver (object-type `SELF` is out of scope). */
export function plsqlReceiverBinding(_functionScope: Scope): TypeRef | null {
return null;
}

View file

@ -0,0 +1,208 @@
/**
* Rename for PL/SQL packages and subprograms.
*
* Every identifier-like token of a file becomes an occurrence:
* - declaration names (body, spec declaration) carry their definition id;
* - call parts, package qualifiers and `END <label>`s carry the reference
* site whose binding the scope resolver records;
* - expression identifiers that are not recorded call sites (variables,
* columns — or argument-less function calls the grammar cannot tell apart)
* are left unproven, so a same-named one makes the engine refuse;
* - everything else is `unrelated`.
*
* A spec declaration and its body (and a package spec and its body) form one
* symbol family and are renamed together. Rename is refused when the name
* appears in dynamic SQL text or in a segment the grammar could not parse.
*/
import type { Range, SymbolDefinition } from 'gitnexus-shared';
import type {
RenameIssue,
RenameOccurrence,
RenameProvider,
RenameSource,
} from '../../rename-provider.js';
import { extractParsedFile } from '../../scope-extractor-bridge.js';
import { preprocessPlSql } from '../../plsql/preprocess.js';
import type { PlSqlDefinition, PlSqlFileFacts } from '../../plsql/extract.js';
import { loadPlSqlParser, plsqlLexTokens } from '../../plsql/facts-store.js';
import { normaliseIdentifier } from '../../plsql/names.js';
import { PlSqlPackageInstances, plsqlSignatureKey, rootPackageOf } from '../../plsql/resolve.js';
import { plsqlFactsFor, plsqlNameRange, plsqlRangeKey, plsqlTokenRange } from './captures.js';
const unsupported = (message: string): RenameIssue => ({
code: 'unsupported_occurrence',
message,
status: 'unsupported',
});
const evidence = new WeakMap<RenameSource, PlSqlFileFacts>();
/** Reserved words that can never name a PL/SQL object (V$RESERVED_WORDS, reserved = 'Y'). */
const PLSQL_RESERVED = new Set(
'ACCESS ADD ALL ALTER AND ANY AS ASC AUDIT BEGIN BETWEEN BY CASE CHAR CHECK CLUSTER COLUMN COMMENT COMPRESS CONNECT CREATE CURRENT DATE DECIMAL DECLARE DEFAULT DELETE DESC DISTINCT DROP ELSE ELSIF END EXCEPTION EXCLUSIVE EXISTS FILE FLOAT FOR FROM FUNCTION GRANT GROUP HAVING IDENTIFIED IF IMMEDIATE IN INCREMENT INDEX INITIAL INSERT INTEGER INTERSECT INTO IS LEVEL LIKE LOCK LONG LOOP MAXEXTENTS MINUS MLSLABEL MODE MODIFY NOAUDIT NOCOMPRESS NOT NOWAIT NULL NUMBER OF OFFLINE ON ONLINE OPTION OR ORDER PACKAGE PCTFREE PRIOR PROCEDURE PUBLIC RAISE RAW RENAME RESOURCE RETURN REVOKE ROW ROWID ROWNUM ROWS SELECT SESSION SET SHARE SIZE SMALLINT START SUCCESSFUL SYNONYM SYSDATE TABLE THEN TO TRIGGER UID UNION UNIQUE UPDATE USER VALIDATE VALUES VARCHAR VARCHAR2 VIEW WHENEVER WHEN WHERE WHILE WITH'.split(
' ',
),
);
const IDENTIFIER_TOKEN = /^(?:[A-Za-z][\w$#]*|"[^"]+")$/;
function definitionFor(
source: RenameSource,
symbol: SymbolDefinition,
): PlSqlDefinition | undefined {
const facts = evidence.get(source);
if (!facts || !symbol.nameRange) return undefined;
const key = plsqlRangeKey(symbol.nameRange);
return facts.definitions.find((d) => plsqlRangeKey(plsqlNameRange(d)) === key);
}
export const plsqlRenameProvider: RenameProvider = {
canonicalName: (name) => normaliseIdentifier(name),
validateName(name) {
if (!/^[A-Za-z][A-Za-z0-9_$#]{0,127}$/.test(name) || PLSQL_RESERVED.has(name.toUpperCase())) {
return {
code: 'invalid_name',
message:
'Use an unquoted PL/SQL identifier: a letter followed by letters, digits, _, $ or #, at most 128 characters, and no reserved word.',
};
}
},
matchesGraphIdentity({ definition, node }) {
return (
definition.type === node.label &&
definition.declarationRange !== undefined &&
definition.declarationRange.startLine === node.properties.startLine! + 1
);
},
async parseSource({ filePath, content, languageProvider }) {
await loadPlSqlParser();
const facts = plsqlFactsFor(content, filePath);
const parsed = extractParsedFile(languageProvider, content, filePath);
if (!parsed)
throw {
code: 'incomplete_semantics',
message: `Could not extract PL/SQL bindings in ${filePath}.`,
};
const definitions = new Map(
parsed.localDefs.filter((d) => d.nameRange).map((d) => [plsqlRangeKey(d.nameRange!), d]),
);
const references = new Map(
parsed.referenceSites.filter((s) => s.nameRange).map((s) => [plsqlRangeKey(s.nameRange!), s]),
);
const unproven = new Set(facts.expressionNames.map((t) => plsqlRangeKey(plsqlTokenRange(t))));
const lineStarts = [0];
for (let i = 0; i < content.length; i++) if (content[i] === '\n') lineStarts.push(i + 1);
// Pre-processing keeps every line and column of the remaining text, so
// token positions map straight back onto the original content.
const occurrences = new Map<string, RenameOccurrence>();
for (const token of plsqlLexTokens(preprocessPlSql(content))) {
const text = token.text;
if (!IDENTIFIER_TOKEN.test(text)) continue;
const range: Range = {
startLine: token.line,
startCol: token.column,
endLine: token.line,
endCol: token.column + text.length,
};
const key = plsqlRangeKey(range);
const definition = definitions.get(key);
const referenceSite = references.get(key);
occurrences.set(key, {
name: normaliseIdentifier(text),
range,
spans: [{ start: lineStarts[token.line - 1]! + token.column, length: text.length }],
...(definition ? { definitionId: definition.nodeId } : {}),
...(referenceSite ? { referenceSite } : {}),
role: definition || referenceSite || unproven.has(key) ? 'name' : 'unrelated',
});
}
const source: RenameSource = {
path: filePath,
content,
parsed,
occurrences: [...occurrences.values()],
};
evidence.set(source, facts);
return source;
},
symbolFamily(context) {
const targetSource = context.files.get(context.target.filePath);
const target = targetSource && definitionFor(targetSource, context.target);
if (!target) return unsupported('The PL/SQL declaration has no source identity.');
// One family per package instance: a spec, its paired body and the
// declarations inside them. Same-named copies elsewhere are other objects.
const packages: Array<{ name: string; schema?: string; filePath: string; isSpec: boolean }> =
[];
for (const file of context.files.values()) {
for (const d of evidence.get(file)?.definitions ?? []) {
if (d.kind === 'package')
packages.push({ name: d.name, schema: d.schema, filePath: file.path, isSpec: d.isSpec });
}
}
const instances = new PlSqlPackageInstances(packages);
const scopeOf = (d: PlSqlDefinition, filePath: string): string => {
const defs = evidence.get(context.files.get(filePath)!)?.definitions ?? [];
const pkg = rootPackageOf(d, defs);
return pkg ? instances.instanceOf(pkg.name, pkg.schema, filePath) : `\0${filePath}`;
};
const targetScope = scopeOf(target, context.target.filePath);
const sameEntity = (d: PlSqlDefinition, filePath: string) =>
scopeOf(d, filePath) === targetScope &&
(target.kind === 'package'
? d.kind === 'package' && d.name === target.name
: d.kind === target.kind && plsqlSignatureKey(d) === plsqlSignatureKey(target));
return context.definitions.filter((symbol) => {
const source = context.files.get(symbol.filePath);
const def = source && definitionFor(source, symbol);
return def !== undefined && sameEntity(def, symbol.filePath);
});
},
checkSafety(context) {
const source = context.files.get(context.target.filePath);
const target = source && definitionFor(source, context.target);
if (!target) return unsupported('The PL/SQL declaration has no source identity.');
if (target.fromFallback)
return unsupported(
'The declaration was recovered without a full parse; rename cannot prove its references.',
);
const escaped = target.name.replace(/[.*+?^${}()|[\]\\$#]/g, '\\$&');
const word = new RegExp(`(^|[^\\w$#])${escaped}($|[^\\w$#])`, 'i');
for (const file of context.files.values()) {
const facts = evidence.get(file);
if (!facts) continue;
const dynamic = facts.dynamicSql.find((d) =>
d.literal === undefined ? false : word.test(d.literal),
);
if (dynamic)
return unsupported(
`${target.name} appears in dynamic SQL at ${file.path}:${dynamic.line}; rename cannot rewrite it safely.`,
);
// SQL*Plus client commands (`EXEC pkg.proc(…)`) are removed before
// parsing, so the names they mention are invisible to the occurrence
// inventory; refuse instead of leaving them stale.
// Script includes name files, not identifiers (`@@pkg_log.pks`).
const includeLines = new Set(facts.scriptIncludes.map((inc) => inc.line));
const command = facts.sqlplusCommands.find(
(c) => !includeLines.has(c.line) && word.test(c.text),
);
if (command)
return unsupported(
`${target.name} appears in a SQL*Plus command at ${file.path}:${command.line}; rename cannot rewrite client commands.`,
);
if (facts.segmentsWithErrors > 0 && word.test(file.content)) {
return unsupported(
`${file.path} has PL/SQL the grammar could not fully parse and mentions ${target.name}.`,
);
}
}
},
};

View file

@ -0,0 +1,221 @@
/**
* PL/SQL `ScopeResolver` registered in `SCOPE_RESOLVERS`.
*
* Structural CONTAINS / CALLS / ACCESSES / IMPORTS edges are owned by
* `plsql-processor.ts` (`callable-flow-only` edge mode, COBOL precedent).
* This resolver contributes the declaration model and, when a consumer such
* as rename asks for it, the exact source binding of every written name —
* decided by the same `PlSqlNameIndex` the processor uses for CALLS.
*
* Reference: `languages/cobol/scope-resolver.ts`.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { plsqlProvider } from '../plsql.js';
import {
PlSqlNameIndex,
PlSqlPackageInstances,
rootPackageOf,
type PlSqlIndexEntry,
} from '../../plsql/resolve.js';
import { resolvePlSqlScriptInclude } from '../../plsql/script-include.js';
import type { PlSqlCallSite, PlSqlDefinition } from '../../plsql/extract.js';
import { plsqlFactsFor, plsqlNameRange, plsqlRangeKey, plsqlTokenRange } from './captures.js';
interface BoundEntry extends PlSqlIndexEntry {
readonly symbol: SymbolDefinition;
}
type SiteMeaning =
| { kind: 'callee'; call: PlSqlCallSite }
| { kind: 'qualifier'; call: PlSqlCallSite; part: number }
| { kind: 'end-label'; def: PlSqlDefinition };
const plsqlScopeResolver: ScopeResolver = {
language: SupportedLanguages.PlSql,
languageProvider: plsqlProvider,
importEdgeReason: 'plsql-scope: script-include',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) =>
resolvePlSqlScriptInclude(targetRaw, fromFile, allFilePaths),
mergeBindings: (existing) => [...existing],
// Defaulted parameters make arity a range, not a single count.
arityCompatibility: (callsite, def) => {
if (callsite.arity === undefined || def.parameterCount === undefined) return 'unknown';
const required = def.requiredParameterCount ?? def.parameterCount;
return callsite.arity >= required && callsite.arity <= def.parameterCount
? 'compatible'
: 'incompatible';
},
scopeResolutionEdgeMode: 'callable-flow-only',
// Object-type inheritance (UNDER) is not modelled yet.
buildMro: () => new Map(),
populateOwners: (_parsed: ParsedFile) => {},
emitPostResolutionEdges(_graph, parsedFiles, _nodeLookup, _indexes, context) {
if (!context?.recordSourceBinding) return;
const record = context.recordSourceBinding;
const index = new PlSqlNameIndex<BoundEntry>();
const entries: Array<{ entry: BoundEntry; fileDefs: readonly PlSqlDefinition[] }> = [];
const symbolOf = new Map<PlSqlDefinition, SymbolDefinition>();
const files = parsedFiles.map((parsed) => {
const facts = plsqlFactsFor(context.fileContents.get(parsed.filePath) ?? '', parsed.filePath);
const byNameRange = new Map(
parsed.localDefs.filter((d) => d.nameRange).map((d) => [plsqlRangeKey(d.nameRange!), d]),
);
for (const def of facts.definitions) {
const symbol = byNameRange.get(plsqlRangeKey(plsqlNameRange(def)));
if (!symbol) continue;
symbolOf.set(def, symbol);
const entry: BoundEntry = {
def,
filePath: parsed.filePath,
symbol,
packageSchema:
def.kind === 'package' ? undefined : rootPackageOf(def, facts.definitions)?.schema,
};
entries.push({ entry, fileDefs: facts.definitions });
index.add(entry);
}
return { parsed, facts };
});
// Same package instances as the processor, so a declaration and its body
// are weighed as one entity (see `PlSqlNameIndex.resolve`).
const instances = new PlSqlPackageInstances(
entries
.filter(({ entry }) => entry.def.kind === 'package')
.map(({ entry }) => ({
name: entry.def.name,
schema: entry.def.schema,
filePath: entry.filePath,
isSpec: entry.def.isSpec,
})),
);
for (const { entry, fileDefs } of entries) {
const pkg = rootPackageOf(entry.def, fileDefs);
if (pkg) entry.instance = instances.instanceOf(pkg.name, pkg.schema, entry.filePath);
}
// The symbol a package instance is known by: its spec when indexed.
const packageOfInstance = new Map<string, { symbol: SymbolDefinition; isSpec: boolean }>();
for (const [def, symbol] of symbolOf) {
if (def.kind !== 'package') continue;
const instance = instances.instanceOf(def.name, def.schema, symbol.filePath);
const known = packageOfInstance.get(instance);
if (!known || (def.isSpec && !known.isSpec))
packageOfInstance.set(instance, { symbol, isSpec: def.isSpec });
}
const resolved = new Map<PlSqlCallSite, BoundEntry[]>();
const resolveCall = (call: PlSqlCallSite, fromFile: string): BoundEntry[] => {
let targets = resolved.get(call);
if (!targets) {
targets = call.dbLink ? [] : index.resolve(call, fromFile);
resolved.set(call, targets);
}
return targets;
};
/**
* The package a qualifier names, read off the resolved callee: `A` in
* `A.N` / `S.A.N` denotes a package only when `N` resolves to members of
* exactly one instance of package `A`. A schema (`S.N` reaching a
* standalone subprogram), a database link, an unresolved callee or members
* of several instances leave the qualifier unproven.
*/
const qualifierPackage = (
call: PlSqlCallSite,
qualifier: string,
fromFile: string,
): SymbolDefinition | undefined => {
const owners = new Set<string | undefined>(
resolveCall(call, fromFile).map((t) =>
t.def.ownerPath.length === 1 && t.def.ownerPath[0] === qualifier ? t.instance : undefined,
),
);
if (owners.size !== 1) return undefined;
const [instance] = owners;
return instance === undefined ? undefined : packageOfInstance.get(instance)?.symbol;
};
for (const { parsed, facts } of files) {
const meaning = new Map<string, SiteMeaning>();
for (const call of facts.calls) {
call.nameTokens.forEach((token, part) => {
meaning.set(
plsqlRangeKey(plsqlTokenRange(token)),
part === call.nameTokens.length - 1
? { kind: 'callee', call }
: { kind: 'qualifier', call, part },
);
});
}
for (const def of facts.definitions) {
for (const label of def.endLabels)
meaning.set(plsqlRangeKey(plsqlTokenRange(label)), { kind: 'end-label', def });
}
for (const site of parsed.referenceSites) {
const m = site.nameRange && meaning.get(plsqlRangeKey(site.nameRange));
if (!m) continue;
if (m.kind === 'end-label') {
const symbol = symbolOf.get(m.def);
record(
site,
symbol
? { status: 'resolved', targetDefIds: [symbol.nodeId], basis: 'plsql-end-label' }
: { status: 'unresolved', targetDefIds: [], basis: 'plsql-end-label' },
);
continue;
}
if (m.kind === 'qualifier') {
// In `S.A.N` only `A` (the part before the callee) can name a package.
const symbol =
m.part === m.call.nameParts.length - 2
? qualifierPackage(m.call, m.call.nameParts[m.part]!, parsed.filePath)
: undefined;
record(
site,
symbol
? {
status: 'resolved',
targetDefIds: [symbol.nodeId],
basis: 'plsql-package-qualifier',
}
: { status: 'unresolved', targetDefIds: [], basis: 'plsql-qualifier' },
);
continue;
}
const ids = resolveCall(m.call, parsed.filePath).map((t) => t.symbol.nodeId);
record(
site,
ids.length === 1
? { status: 'resolved', targetDefIds: ids, basis: 'plsql-call' }
: ids.length > 1
? {
status: 'ambiguous',
targetDefIds: ids,
candidatesComplete: true,
basis: 'plsql-overload',
}
: { status: 'unresolved', targetDefIds: [], basis: 'plsql-call' },
);
}
}
},
isSuperReceiver: () => false,
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: false,
};
export { plsqlScopeResolver };

View file

@ -11,6 +11,7 @@ export { scanPhase, type ScanOutput } from './scan.js';
export { structurePhase, type StructureOutput } from './structure.js';
export { markdownPhase, type MarkdownOutput } from './markdown.js';
export { cobolPhase, type CobolOutput } from './cobol.js';
export { plsqlPhase, type PlSqlOutput } from './plsql.js';
export { parsePhase, type ParseOutput } from './parse.js';
export { routesPhase, type RoutesOutput, type RouteEntry } from './routes.js';
export { toolsPhase, type ToolsOutput, type ToolDef } from './tools.js';

View file

@ -9,7 +9,7 @@
* from the original pipeline. The chunk loop is a memory optimization
* internal to this phase, not a phase boundary.
*
* @deps structure, markdown, cobol
* @deps structure, markdown, cobol, plsql
* @reads scannedFiles, allPaths, totalFiles (from structure)
* @writes graph (Symbol nodes, IMPORTS/CALLS/EXTENDS/IMPLEMENTS/ACCESSES edges)
* @output exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes,
@ -117,7 +117,7 @@ export interface ParseOutput {
export const parsePhase: PipelinePhase<ParseOutput> = {
name: 'parse',
deps: ['structure', 'markdown', 'cobol'],
deps: ['structure', 'markdown', 'cobol', 'plsql'],
async execute(
ctx: PipelineContext,

View file

@ -0,0 +1,107 @@
/**
* Phase: plsql
*
* Processes Oracle PL/SQL files with the ANTLR4 PlSql grammar (no tree-sitter).
* Dedicated extensions (.pks, .pkb, …) are always included; `.sql` files only
* when their content holds an Oracle PL/SQL unit.
*
* @deps structure
* @reads scannedFiles (from structure phase)
* @writes graph (package Module, subprogram/trigger Function, table Record
* nodes; CONTAINS / CALLS / ACCESSES edges)
*/
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import { processPlSql, isPlSqlSource } from '../plsql-processor.js';
import { hasPlSqlExtension, isSqlFile } from '../plsql/file-types.js';
import { loadPlSqlParser, resetPlSqlFactsStore } from '../plsql/facts-store.js';
import { readFileContents } from '../filesystem-walker.js';
import type { StructureOutput } from './structure.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
/** Bytes of candidate source read at once while classifying `.sql` files. */
const READ_BATCH_BYTES = 16 * 1024 * 1024;
function byteBudgetBatches<T extends { size: number }>(items: readonly T[], budget: number): T[][] {
const batches: T[][] = [];
let current: T[] = [];
let bytes = 0;
for (const item of items) {
if (current.length > 0 && bytes + item.size > budget) {
batches.push(current);
current = [];
bytes = 0;
}
current.push(item);
bytes += item.size;
}
if (current.length > 0) batches.push(current);
return batches;
}
export interface PlSqlOutput {
files: number;
packages: number;
subprograms: number;
}
export const plsqlPhase: PipelinePhase<PlSqlOutput> = {
name: 'plsql',
deps: ['structure'],
async execute(
ctx: PipelineContext,
deps: ReadonlyMap<string, PhaseResult<unknown>>,
): Promise<PlSqlOutput> {
const { scannedFiles, allPathSet } = getPhaseOutput<StructureOutput>(deps, 'structure');
const candidates = scannedFiles.filter((f) => hasPlSqlExtension(f.path) || isSqlFile(f.path));
if (candidates.length === 0) {
resetPlSqlFactsStore(0); // drop a previous run's facts in long-lived processes
return { files: 0, packages: 0, subprograms: 0 };
}
// Read in byte-budgeted batches and keep only Oracle sources, so a repo
// full of other-dialect `.sql` files never holds them all in memory.
const files: Array<{ path: string; content: string }> = [];
for (const batch of byteBudgetBatches(candidates, READ_BATCH_BYTES)) {
const contents = await readFileContents(
ctx.repoPath,
batch.map((f) => f.path),
);
for (const f of batch) {
const content = contents.get(f.path);
if (content !== undefined && isPlSqlSource(f.path, content)) {
files.push({ path: f.path, content });
}
}
}
if (files.length === 0) {
resetPlSqlFactsStore(0);
return { files: 0, packages: 0, subprograms: 0 };
}
// The ANTLR parser is loaded only now that the repository is known to hold PL/SQL.
await loadPlSqlParser();
const result = processPlSql(ctx.graph, files, allPathSet);
if (isDev) {
logger.info(
` PL/SQL: ${result.packages} packages, ${result.subprograms} subprograms, ${result.triggers} triggers, ${result.tables} tables from ${result.files} files`,
);
logger.info(
` PL/SQL edges: ${result.calls} calls (${result.ambiguousCalls} ambiguous, ${result.unresolvedCalls} unresolved), ${result.accesses} table accesses, ${result.dynamicSqlSites} dynamic SQL sites`,
);
if (result.segmentsWithErrors > 0) {
logger.info(
` PL/SQL degraded: ${result.segmentsWithErrors} segments with syntax errors (${result.segmentsTimedOut} at the parse deadline), ${result.fallbackDefinitions} fallback definitions`,
);
}
}
return { files: result.files, packages: result.packages, subprograms: result.subprograms };
},
};

View file

@ -29,6 +29,7 @@ import {
structurePhase,
markdownPhase,
cobolPhase,
plsqlPhase,
parsePhase,
routesPhase,
toolsPhase,
@ -324,7 +325,7 @@ export interface PipelineOptions {
*
* Phase dependency graph:
*
* scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm]
* scan → structure → [springConfig, markdown, cobol, plsql] → parse → [routes, tools, orm]
* → crossFile → scopeResolution → [springAutoConfiguration, springAop,
* springDestinations] → pruneLocalSymbols
* → mro → springAopInheritance → di → communities → processes
@ -347,6 +348,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
.register(springConfigPhase)
.register(markdownPhase)
.register(cobolPhase)
.register(plsqlPhase)
.register(parsePhase)
.register(routesPhase)
.register(toolsPhase)

View file

@ -0,0 +1,461 @@
/**
* Oracle PL/SQL → knowledge graph.
*
* Standalone processor (COBOL precedent): parses PL/SQL files with the
* ANTLR4 grammar (`plsql/`), resolves names across the whole repository and
* writes structural nodes and edges directly into the graph before the parse
* phase. The PL/SQL scope resolver runs in `callable-flow-only` mode, so every
* structural edge below is owned here.
*
* Mapping (no schema extension):
* package (spec + body) → Module (one node, anchored at the spec)
* procedure / function / trigger → Function (CONTAINS from its owner)
* table / view → Record (one node per table name)
* call → CALLS (reason plsql-call*)
* DML / SELECT / trigger target → ACCESSES (reason plsql-<op>)
* SQL*Plus @@script / START → IMPORTS (File → File)
*
* Resolution is deliberately conservative: a call only becomes an edge when it
* resolves to a definition indexed from this repository. Built-ins
* (`TRUNC`, `DBMS_OUTPUT.PUT_LINE`) and objects outside the repository yield
* no edge rather than a guessed one; overloads that arity and named arguments
* cannot tell apart yield one lower-confidence edge per candidate.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { generateId } from '../../lib/utils.js';
import type { KnowledgeGraph } from '../graph/types.js';
import { toZeroBasedLine } from './utils/line-base.js';
import type { PlSqlDefinition, PlSqlFileFacts } from './plsql/extract.js';
import { plsqlFactsForFile, resetPlSqlFactsStore } from './plsql/facts-store.js';
import { isPlSqlSource } from './plsql/file-types.js';
import { resolvePlSqlScriptInclude } from './plsql/script-include.js';
import {
PlSqlNameIndex,
PlSqlPackageInstances,
plsqlSignatureKey,
rootPackageOf,
type PlSqlIndexEntry,
} from './plsql/resolve.js';
export interface PlSqlFile {
readonly path: string;
readonly content: string;
}
export interface PlSqlProcessResult {
readonly files: number;
readonly packages: number;
readonly subprograms: number;
readonly triggers: number;
readonly tables: number;
readonly calls: number;
readonly ambiguousCalls: number;
readonly unresolvedCalls: number;
readonly accesses: number;
readonly scriptIncludes: number;
readonly dynamicSqlSites: number;
readonly segmentsWithErrors: number;
/** Segments abandoned at the parse deadline (a subset of `segmentsWithErrors`). */
readonly segmentsTimedOut: number;
readonly fallbackDefinitions: number;
}
export { isPlSqlSource };
// ─── node identity ────────────────────────────────────────────────────────
/**
* A package is one node for spec and body, keyed by the file that declares it.
* The schema joins the key only when one file declares same-named packages of
* several schemas, so ordinary ids stay `Module:<file>:<name>`.
*/
export function plsqlPackageNodeId(homeFile: string, packageName: string, schema?: string): string {
return generateId('Module', `${homeFile}:${schema ? `${schema}.` : ''}${packageName}`);
}
/**
* Subprograms are keyed by owner path plus the normalised parameter signature,
* so overloads get distinct ids that survive edits elsewhere in the file.
*/
export function plsqlSubprogramNodeId(
filePath: string,
def: PlSqlDefinition,
packageSchema?: string,
): string {
const schema = packageSchema ? `${packageSchema}.` : '';
return generateId(
'Function',
`${filePath}:${schema}${plsqlSignatureKey(def)}${def.kind === 'trigger' ? ':trigger' : ''}`,
);
}
export function plsqlTableNodeId(tableName: string): string {
return generateId('Record', `plsql-table:${tableName}`);
}
interface Entry extends PlSqlIndexEntry {
/** Graph node of this definition (a declaration with a body maps to the body). */
nodeId?: string;
}
// ─── graph mapping ────────────────────────────────────────────────────────
export function processPlSql(
graph: KnowledgeGraph,
files: readonly PlSqlFile[],
allPaths: ReadonlySet<string> = new Set(files.map((f) => f.path)),
): PlSqlProcessResult {
resetPlSqlFactsStore(files.length);
const facts = new Map<string, PlSqlFileFacts>();
for (const file of files) facts.set(file.path, plsqlFactsForFile(file.path, file.content));
const index = new PlSqlNameIndex<Entry>();
const entryOf = new Map<PlSqlDefinition, Entry>();
for (const [filePath, f] of facts) {
for (const def of f.definitions) {
const entry: Entry = {
def,
filePath,
packageSchema:
def.kind === 'package' ? undefined : rootPackageOf(def, f.definitions)?.schema,
};
entryOf.set(def, entry);
index.add(entry);
}
}
// ── package instances: each spec paired with its nearest compatible body ──
const packageDefs = [...entryOf.values()].filter((e) => e.def.kind === 'package');
const instances = new PlSqlPackageInstances(
packageDefs.map((e) => ({
name: e.def.name,
schema: e.def.schema,
filePath: e.filePath,
isSpec: e.def.isSpec,
})),
);
const instanceOfPackage = (pkg: PlSqlDefinition, filePath: string) =>
instances.instanceOf(pkg.name, pkg.schema, filePath);
/** The package instance a definition in `filePath` belongs to, if it is a package member. */
const memberInstance = (def: PlSqlDefinition, filePath: string): string | undefined => {
if (def.kind === 'package') return undefined;
const pkg = rootPackageOf(def, facts.get(filePath)!.definitions);
return pkg ? instanceOfPackage(pkg, filePath) : undefined;
};
/** Pairs a spec/forward declaration with its body inside one package instance (or one file). */
const pairingKey = (def: PlSqlDefinition, filePath: string) =>
`${memberInstance(def, filePath) ?? `\0${filePath}`}|${plsqlSignatureKey(def)}`;
const packageNodeByInstance = new Map<string, string>();
const instanceFiles = new Map<string, { home: string; anchor: Entry; files: Set<string> }>();
for (const entry of packageDefs) {
const key = instanceOfPackage(entry.def, entry.filePath);
const instance = instanceFiles.get(key);
if (!instance) {
instanceFiles.set(key, {
home: instances.homeOf(key) ?? entry.filePath,
anchor: entry,
files: new Set([entry.filePath]),
});
} else {
instance.files.add(entry.filePath);
if (entry.def.isSpec && !instance.anchor.def.isSpec) instance.anchor = entry;
}
}
const homeClashes = new Map<string, number>();
for (const instance of instanceFiles.values()) {
const k = `${instance.home}\0${instance.anchor.def.name}`;
homeClashes.set(k, (homeClashes.get(k) ?? 0) + 1);
}
for (const [key, instance] of instanceFiles) {
const { name, schema } = instance.anchor.def;
const clash = (homeClashes.get(`${instance.home}\0${name}`) ?? 0) > 1;
const nodeId = plsqlPackageNodeId(instance.home, name, clash ? schema : undefined);
packageNodeByInstance.set(key, nodeId);
const others = [...instance.files].filter((f) => f !== instance.home).sort();
graph.addNode({
id: nodeId,
label: 'Module',
properties: {
name,
filePath: instance.home,
startLine: toZeroBasedLine(instance.anchor.def.startLine),
endLine: toZeroBasedLine(instance.anchor.def.endLine),
language: SupportedLanguages.PlSql,
isExported: true,
description:
others.length > 0
? `PL/SQL package; also defined in ${others.join(', ')}`
: 'PL/SQL package',
},
});
for (const file of instance.files) {
addEdge(graph, 'CONTAINS', generateId('File', file), nodeId, 1.0, 'plsql-package');
}
}
for (const entry of packageDefs) {
entry.nodeId = packageNodeByInstance.get(instanceOfPackage(entry.def, entry.filePath));
entry.instance = instanceOfPackage(entry.def, entry.filePath);
}
for (const entry of entryOf.values()) {
if (entry.def.kind !== 'package') entry.instance = memberInstance(entry.def, entry.filePath);
}
// ── subprograms and triggers ──
const bodyKeys = new Set<string>();
const publicKeys = new Set<string>();
for (const { def, filePath } of entryOf.values()) {
if (def.kind === 'package') continue;
if (!def.declarationOnly) bodyKeys.add(pairingKey(def, filePath));
if (def.isSpec) publicKeys.add(pairingKey(def, filePath));
}
const bodyNodeByKey = new Map<string, string>();
let subprograms = 0;
let triggers = 0;
let fallbackDefinitions = 0;
for (const entry of entryOf.values()) {
const { def, filePath } = entry;
if (def.kind === 'package') continue;
if (def.fromFallback) fallbackDefinitions++;
const key = pairingKey(def, filePath);
// A spec/forward declaration with an indexed body is the same subprogram.
if (def.declarationOnly && bodyKeys.has(key)) continue;
// Package members carry their package's schema; standalone subprograms
// their own (`CREATE PROCEDURE a.p` and `b.p` in one file are two objects).
const schema = entry.packageSchema ?? (def.ownerPath.length === 0 ? def.schema : undefined);
entry.nodeId = plsqlSubprogramNodeId(filePath, def, schema);
if (!def.declarationOnly) bodyNodeByKey.set(key, entry.nodeId);
if (def.kind === 'trigger') triggers++;
else subprograms++;
const packageMember = def.ownerPath.length === 1 && memberInstance(def, filePath) !== undefined;
graph.addNode({
id: entry.nodeId,
label: 'Function',
properties: {
name: def.name,
filePath,
startLine: toZeroBasedLine(def.startLine),
endLine: toZeroBasedLine(def.endLine),
language: SupportedLanguages.PlSql,
// Package members are public exactly when the package spec declares them.
isExported: packageMember ? publicKeys.has(key) : def.ownerPath.length === 0,
parameterCount: def.parameters.length,
description: describeDefinition(def),
},
});
}
for (const entry of entryOf.values()) {
if (entry.def.declarationOnly && !entry.nodeId) {
entry.nodeId = bodyNodeByKey.get(pairingKey(entry.def, entry.filePath));
}
}
/**
* Graph node that owns a site or definition: the definition at `ownerIndex`
* (by position, so overloads keep their own sites), or — for fallback
* definitions, which carry no index — the file's package instance.
*/
const ownerNode = (
filePath: string,
fileFacts: PlSqlFileFacts,
site: { readonly ownerIndex?: number; readonly ownerPath: readonly string[] },
): string | undefined => {
if (site.ownerIndex !== undefined) {
const owner = fileFacts.definitions[site.ownerIndex]!;
return entryOf.get(owner)?.nodeId;
}
const pkgName = site.ownerPath[0];
const pkg =
site.ownerPath.length === 1
? fileFacts.definitions.find((d) => d.kind === 'package' && d.name === pkgName)
: undefined;
if (pkg) return packageNodeByInstance.get(instanceOfPackage(pkg, filePath));
return undefined;
};
for (const [filePath, f] of facts) {
for (const def of f.definitions) {
const nodeId = entryOf.get(def)?.nodeId;
if (def.kind === 'package' || !nodeId || def.declarationOnly) continue;
const owner = ownerNode(filePath, f, def) ?? generateId('File', filePath);
addEdge(
graph,
'CONTAINS',
owner,
nodeId,
1.0,
def.kind === 'trigger' ? 'plsql-trigger' : 'plsql-subprogram',
);
}
}
// Spec-only subprograms (no body indexed) hang off their package.
for (const entry of entryOf.values()) {
const { def, filePath, nodeId } = entry;
if (!def.declarationOnly || !nodeId || bodyKeys.has(pairingKey(def, filePath))) continue;
const facts0 = facts.get(filePath)!;
const owner = ownerNode(filePath, facts0, def) ?? generateId('File', filePath);
addEdge(graph, 'CONTAINS', owner, nodeId, 1.0, 'plsql-subprogram');
}
// ── calls ──
let calls = 0;
let ambiguousCalls = 0;
let unresolvedCalls = 0;
for (const [filePath, f] of facts) {
const degraded = f.segmentsWithErrors > 0;
for (const call of f.calls) {
const sourceId = ownerNode(filePath, f, call);
if (!sourceId) continue; // top-level script statements have no callable owner
const targets = call.dbLink
? []
: index.resolve(call, filePath).filter((t) => t.nodeId !== undefined);
const targetIds = [...new Set(targets.map((t) => t.nodeId!))];
if (targetIds.length === 0) {
if (call.form === 'statement') unresolvedCalls++;
continue;
}
const ambiguous = targetIds.length > 1;
if (ambiguous) ambiguousCalls++;
const fallbackTarget = targets.some((t) => t.def.fromFallback);
for (const targetId of targetIds) {
graph.addRelationship({
id: generateId('CALLS', `${sourceId}->${targetId}:L${call.line}:${call.column}`),
type: 'CALLS',
sourceId,
targetId,
confidence: ambiguous ? 0.5 : degraded || fallbackTarget ? 0.7 : 1.0,
reason: ambiguous ? 'plsql-call-ambiguous' : 'plsql-call',
});
calls++;
}
}
}
// ── table accesses ──
const tables = new Set<string>();
let accesses = 0;
for (const [filePath, f] of facts) {
for (const access of f.accesses) {
const sourceId = ownerNode(filePath, f, access);
if (!sourceId) continue;
// A table over a database link is a remote object, not the local table
// of the same name: its identity keeps the link.
const tableName = access.dbLink ? `${access.table}@${access.dbLink}` : access.table;
const tableId = plsqlTableNodeId(tableName);
if (!tables.has(tableName)) {
tables.add(tableName);
graph.addNode({
id: tableId,
label: 'Record',
properties: {
name: tableName,
// A table is a database object, not source; anchor it at its first
// reference so it stays navigable.
filePath,
startLine: toZeroBasedLine(access.line),
endLine: toZeroBasedLine(access.line),
language: SupportedLanguages.PlSql,
description: access.dbLink
? `remote table or view over database link ${access.dbLink}, referenced from PL/SQL`
: 'database table or view referenced from PL/SQL',
},
});
}
graph.addRelationship({
id: generateId(
'ACCESSES',
`${sourceId}->${tableId}:${access.kind}:L${access.line}:${access.column}`,
),
type: 'ACCESSES',
sourceId,
targetId: tableId,
confidence: f.segmentsWithErrors > 0 ? 0.7 : 1.0,
reason: `plsql-${access.kind}`,
});
accesses++;
}
}
// ── SQL*Plus script includes ──
let scriptIncludes = 0;
for (const [filePath, f] of facts) {
for (const include of f.scriptIncludes) {
const target = resolvePlSqlScriptInclude(
include.target,
filePath,
allPaths,
include.relativeToScript,
);
if (!target) continue;
addEdge(
graph,
'IMPORTS',
generateId('File', filePath),
generateId('File', target),
1.0,
'plsql-script-include',
`:L${include.line}`,
);
scriptIncludes++;
}
}
let segmentsWithErrors = 0;
let segmentsTimedOut = 0;
let dynamicSqlSites = 0;
for (const f of facts.values()) {
segmentsWithErrors += f.segmentsWithErrors;
segmentsTimedOut += f.segmentsTimedOut;
dynamicSqlSites += f.dynamicSql.length;
}
return {
files: files.length,
packages: packageNodeByInstance.size,
subprograms,
triggers,
tables: tables.size,
calls,
ambiguousCalls,
unresolvedCalls,
accesses,
scriptIncludes,
dynamicSqlSites,
segmentsWithErrors,
segmentsTimedOut,
fallbackDefinitions,
};
}
function addEdge(
graph: KnowledgeGraph,
type: 'CONTAINS' | 'IMPORTS',
sourceId: string,
targetId: string,
confidence: number,
reason: string,
idSuffix = '',
): void {
graph.addRelationship({
id: generateId(type, `${sourceId}->${targetId}${idSuffix}`),
type,
sourceId,
targetId,
confidence,
reason,
});
}
function describeDefinition(def: PlSqlDefinition): string | undefined {
const parts: string[] = [];
if (def.kind === 'trigger' && def.triggerTable) parts.push(`trigger on ${def.triggerTable}`);
if (def.declarationOnly)
parts.push(
def.isSpec
? 'declared in package spec (no body indexed)'
: 'forward declaration (no body indexed)',
);
if (def.fromFallback) parts.push('recovered by fallback; parameters unknown');
return parts.length > 0 ? parts.join('; ') : undefined;
}

View file

@ -0,0 +1,747 @@
/**
* PL/SQL fact extraction: walks the ANTLR parse tree of every segment and
* returns parser-independent facts (definitions, call sites, table accesses,
* dynamic SQL sites). Segments the grammar could not fully parse additionally
* get regex-level definitions, marked `fromFallback`, for any subprogram the
* error-recovered tree lost.
*
* Names are normalised the Oracle way: unquoted identifiers upper-case,
* quoted identifiers verbatim without quotes.
*/
import {
CharStream,
CommonTokenStream,
ParseTreeWalker,
ParserRuleContext,
Token,
type ParseTree,
type ParseTreeListener,
} from 'antlr4ng';
import { PlSqlParser as P } from './generated/PlSqlParser.js';
import { PlSqlLexer } from './generated/PlSqlLexer.js';
import { parsePlSqlSource, type PlSqlSegment } from './parse.js';
import { normaliseIdentifier } from './names.js';
import type { ScriptInclude, SqlPlusCommand } from './preprocess.js';
export type PlSqlDefKind = 'package' | 'procedure' | 'function' | 'trigger';
export type PlSqlAccessKind =
| 'select'
| 'insert'
| 'update'
| 'delete'
| 'merge'
| 'lock'
| 'trigger';
/** One written identifier: normalised name plus its exact source position. */
export interface PlSqlNameToken {
readonly name: string;
readonly line: number;
/** 0-based column. */
readonly column: number;
readonly length: number;
}
export interface PlSqlParameter {
readonly name: string;
readonly hasDefault: boolean;
/** Normalised type text (`NUMBER`, `T_LOAN.ID%TYPE`); empty when omitted. */
readonly type: string;
}
export interface PlSqlDefinition {
readonly kind: PlSqlDefKind;
readonly name: string;
/** Schema prefix written on a top-level CREATE (`CREATE PACKAGE loan.pkg`). */
readonly schema?: string;
/** Enclosing definition names, outermost first (package, then subprograms). */
readonly ownerPath: readonly string[];
/** Index into `definitions` of the enclosing definition (by position); absent at top level and for fallback definitions. */
readonly ownerIndex?: number;
readonly startLine: number;
/** 0-based column of the first token of the definition. */
readonly startColumn: number;
readonly endLine: number;
/** 0-based column just past the last token of the definition. */
readonly endColumn: number;
readonly nameLine: number;
readonly nameColumn: number;
readonly nameLength: number;
/** `END <name>` closing labels, which must be renamed with the definition. */
readonly endLabels: readonly PlSqlNameToken[];
readonly parameters: readonly PlSqlParameter[];
/**
* Package spec, or a subprogram declared in a package spec — i.e. public.
* Forward declarations inside a package body are NOT spec declarations.
*/
readonly isSpec: boolean;
/**
* Declaration without a body at this position: a package-spec declaration
* or a forward declaration in a package body. Never owns call sites.
*/
readonly declarationOnly: boolean;
/** Trigger only: the table the trigger fires on. */
readonly triggerTable?: string;
/** True when the definition was recovered by the regex fallback. */
readonly fromFallback: boolean;
}
export interface PlSqlCallSite {
readonly ownerPath: readonly string[];
/** Index into `definitions` of the innermost enclosing definition (by position). */
readonly ownerIndex?: number;
readonly line: number;
readonly column: number;
/** Dotted name parts (`PKG_LOG`, `WARN`); optional schema first. */
readonly nameParts: readonly string[];
/** Source position of every name part, aligned with `nameParts`. */
readonly nameTokens: readonly PlSqlNameToken[];
readonly arity?: number;
readonly namedArguments: readonly string[];
/** `statement` = procedure call statement, `expression` = function call in an expression. */
readonly form: 'statement' | 'expression';
readonly dbLink?: string;
}
export interface PlSqlTableAccess {
readonly ownerPath: readonly string[];
/** Index into `definitions` of the innermost enclosing definition (by position). */
readonly ownerIndex?: number;
readonly line: number;
readonly column: number;
readonly table: string;
readonly schema?: string;
readonly dbLink?: string;
readonly kind: PlSqlAccessKind;
}
export interface PlSqlDynamicSqlSite {
readonly ownerPath: readonly string[];
/** Index into `definitions` of the innermost enclosing definition (by position). */
readonly ownerIndex?: number;
readonly line: number;
/** Literal statement text when the argument is a string literal. */
readonly literal?: string;
}
export interface PlSqlFileFacts {
readonly scriptIncludes: readonly ScriptInclude[];
/**
* SQL*Plus client commands removed before parsing. `EXEC pkg.proc(…)` lines
* name subprograms the parse tree never sees, so rename must consult them.
*/
readonly sqlplusCommands: readonly SqlPlusCommand[];
/**
* Identifiers in expression positions that are not recorded call sites:
* variables, columns, record fields — or argument-less function calls the
* grammar cannot tell apart from them. Rename treats a same-named one as
* unproven and refuses rather than guessing.
*/
readonly expressionNames: readonly PlSqlNameToken[];
readonly definitions: readonly PlSqlDefinition[];
readonly calls: readonly PlSqlCallSite[];
readonly accesses: readonly PlSqlTableAccess[];
readonly dynamicSql: readonly PlSqlDynamicSqlSite[];
readonly segments: number;
readonly segmentsWithErrors: number;
readonly segmentsTimedOut: number;
readonly firstError?: string;
}
// ─── helpers ──────────────────────────────────────────────────────────────
export { normaliseIdentifier };
function splitDotted(text: string): string[] {
// `"a.b".c` keeps the quoted part intact.
const parts: string[] = [];
let current = '';
let quoted = false;
for (const ch of text) {
if (ch === '"') quoted = !quoted;
if (ch === '.' && !quoted) {
parts.push(normaliseIdentifier(current));
current = '';
} else current += ch;
}
if (current !== '') parts.push(normaliseIdentifier(current));
return parts;
}
function childRule(ctx: ParserRuleContext, rule: number): ParserRuleContext | undefined {
for (const c of ctx.children)
if (c instanceof ParserRuleContext && c.ruleIndex === rule) return c;
return undefined;
}
function childRules(ctx: ParserRuleContext, rule: number): ParserRuleContext[] {
return ctx.children.filter(
(c): c is ParserRuleContext => c instanceof ParserRuleContext && c.ruleIndex === rule,
);
}
function textOf(tree: ParseTree | undefined): string {
return tree ? tree.getText() : '';
}
function nameTokenOf(ctx: ParserRuleContext): PlSqlNameToken {
return {
name: normaliseIdentifier(ctx.getText()),
line: ctx.start!.line,
column: ctx.start!.column,
length: (ctx.stop?.stop ?? ctx.start!.stop) - ctx.start!.start + 1,
};
}
/** The last written identifier of a (possibly dotted) name rule. */
function lastNameCtx(ctx: ParserRuleContext): ParserRuleContext {
const parts = ctx.children.filter(
(c): c is ParserRuleContext =>
c instanceof ParserRuleContext &&
(c.ruleIndex === P.RULE_identifier || c.ruleIndex === P.RULE_id_expression),
);
return parts[parts.length - 1] ?? ctx;
}
/** Every written identifier of a dotted name rule, in order. */
function nameCtxs(ctx: ParserRuleContext): ParserRuleContext[] {
const parts = ctx.children.filter(
(c): c is ParserRuleContext =>
c instanceof ParserRuleContext &&
(c.ruleIndex === P.RULE_identifier || c.ruleIndex === P.RULE_id_expression),
);
return parts.length > 0 ? parts : [ctx];
}
/** `END <label>` of a subprogram/trigger body or package. */
function endLabelsOf(ctx: ParserRuleContext): PlSqlNameToken[] {
if (ctx.ruleIndex === P.RULE_create_package || ctx.ruleIndex === P.RULE_create_package_body) {
const names = childRules(ctx, P.RULE_package_name);
return names.length > 1 ? [nameTokenOf(lastNameCtx(names[names.length - 1]!))] : [];
}
const findBody = (n: ParserRuleContext, depth: number): ParserRuleContext | undefined => {
for (const c of n.children) {
if (!(c instanceof ParserRuleContext)) continue;
if (c.ruleIndex === P.RULE_body) return c;
if (
depth > 0 &&
(c.ruleIndex === P.RULE_trigger_body || c.ruleIndex === P.RULE_trigger_block)
) {
const nested = findBody(c, depth - 1);
if (nested) return nested;
}
if (c.ruleIndex === P.RULE_compound_trigger_block) {
const label = childRule(c, P.RULE_trigger_name);
return label ? c : undefined;
}
}
return undefined;
};
const body = findBody(ctx, 2);
if (!body) return [];
const label =
body.ruleIndex === P.RULE_compound_trigger_block
? childRule(body, P.RULE_trigger_name)
: childRule(body, P.RULE_label_name);
return label ? [nameTokenOf(lastNameCtx(label))] : [];
}
function parametersOf(ctx: ParserRuleContext): PlSqlParameter[] {
return childRules(ctx, P.RULE_parameter).map((p) => ({
name: normaliseIdentifier(textOf(childRule(p, P.RULE_parameter_name))),
hasDefault: childRule(p, P.RULE_default_value_part) !== undefined,
type: textOf(childRule(p, P.RULE_type_spec)).toUpperCase(),
}));
}
function argumentsOf(fnArg: ParserRuleContext | undefined): { arity?: number; named: string[] } {
if (!fnArg) return { named: [] };
const args = childRules(fnArg, P.RULE_argument);
const named = args.flatMap((a) => {
const id = childRule(a, P.RULE_identifier);
return id ? [normaliseIdentifier(id.getText())] : [];
});
return { arity: args.length, named };
}
/** Decide read vs write for a `tableview_name`, from its nearest deciding ancestor. */
function accessKindOf(ctx: ParserRuleContext): PlSqlAccessKind | undefined {
let prev: ParserRuleContext = ctx;
for (let a = ctx.parent; a; prev = a, a = a.parent) {
switch (a.ruleIndex) {
case P.RULE_query_block:
case P.RULE_subquery:
return 'select';
case P.RULE_insert_into_clause:
return 'insert';
case P.RULE_update_statement:
return 'update';
case P.RULE_delete_statement:
return 'delete';
case P.RULE_merge_statement:
return childRule(a, P.RULE_selected_tableview) === prev ? 'merge' : 'select';
case P.RULE_lock_table_statement:
return 'lock';
case P.RULE_create_trigger:
return 'trigger';
// DDL and type references are not data accesses.
case P.RULE_create_table:
case P.RULE_create_view:
case P.RULE_type_spec:
case P.RULE_parameter:
return undefined;
}
}
return undefined;
}
/** Common-table-expression names visible at a statement (filtered from accesses). */
function cteNamesAbove(ctx: ParserRuleContext): Set<string> {
const names = new Set<string>();
for (let a = ctx.parent; a; a = a.parent) {
const withClause =
a.ruleIndex === P.RULE_select_statement || a.ruleIndex === P.RULE_subquery
? childRule(a, P.RULE_with_clause)
: undefined;
if (!withClause) continue;
const visit = (n: ParserRuleContext) => {
if (n.ruleIndex === P.RULE_query_name) names.add(normaliseIdentifier(n.getText()));
for (const c of n.children) if (c instanceof ParserRuleContext) visit(c);
};
visit(withClause);
}
return names;
}
const PSEUDO_TABLES = new Set(['DUAL']);
/** True for a subprogram spec inside `CREATE PACKAGE` (public), false for a body forward declaration. */
function insidePackageSpec(ctx: ParserRuleContext): boolean {
for (let a = ctx.parent; a; a = a.parent) {
if (a.ruleIndex === P.RULE_create_package) return true;
if (a.ruleIndex === P.RULE_create_package_body) return false;
}
return false;
}
// ─── tree walk ────────────────────────────────────────────────────────────
const SCOPE_RULES = new Set([
P.RULE_create_package,
P.RULE_create_package_body,
P.RULE_procedure_body,
P.RULE_function_body,
P.RULE_create_procedure_body,
P.RULE_create_function_body,
P.RULE_create_trigger,
]);
function extractSegment(
segment: PlSqlSegment,
out: {
definitions: PlSqlDefinition[];
calls: PlSqlCallSite[];
accesses: PlSqlTableAccess[];
dynamicSql: PlSqlDynamicSqlSite[];
expressionNames: PlSqlNameToken[];
},
): void {
if (!segment.tree) return;
const scope: string[] = [];
/** Definition indices parallel to `scope`: sites record the innermost owner by position. */
const owners: number[] = [];
const ownerIndex = () => owners[owners.length - 1];
const define = (
ctx: ParserRuleContext,
kind: PlSqlDefKind,
nameCtx: ParserRuleContext | undefined,
extra: {
schema?: string;
isSpec?: boolean;
declarationOnly?: boolean;
triggerTable?: string;
push?: boolean;
} = {},
) => {
const parts = splitDotted(textOf(nameCtx));
const name = parts[parts.length - 1] ?? '?';
const schema = extra.schema ?? (parts.length > 1 ? parts[parts.length - 2] : undefined);
const token = nameTokenOf(nameCtx ? lastNameCtx(nameCtx) : ctx);
const stop = ctx.stop ?? ctx.start!;
out.definitions.push({
kind,
name,
schema,
ownerPath: [...scope],
ownerIndex: ownerIndex(),
startLine: ctx.start!.line,
startColumn: ctx.start!.column,
endLine: stop.line,
endColumn: stop.column + (stop.stop - stop.start + 1),
nameLine: token.line,
nameColumn: token.column,
nameLength: token.length,
endLabels: endLabelsOf(ctx),
parameters: parametersOf(ctx),
isSpec: extra.isSpec ?? false,
declarationOnly: extra.declarationOnly ?? false,
triggerTable: extra.triggerTable,
fromFallback: false,
});
if (extra.push !== false) {
scope.push(name);
owners.push(out.definitions.length - 1);
}
};
const listener: ParseTreeListener = {
visitTerminal() {},
visitErrorNode() {},
enterEveryRule(ctx: ParserRuleContext) {
switch (ctx.ruleIndex) {
case P.RULE_create_package:
case P.RULE_create_package_body: {
const schemaCtx = childRule(ctx, P.RULE_schema_object_name);
define(ctx, 'package', childRule(ctx, P.RULE_package_name), {
schema: schemaCtx ? normaliseIdentifier(schemaCtx.getText()) : undefined,
isSpec: ctx.ruleIndex === P.RULE_create_package,
});
break;
}
case P.RULE_procedure_spec:
case P.RULE_function_spec:
define(
ctx,
ctx.ruleIndex === P.RULE_procedure_spec ? 'procedure' : 'function',
childRule(ctx, P.RULE_identifier),
{ isSpec: insidePackageSpec(ctx), declarationOnly: true, push: false },
);
break;
case P.RULE_procedure_body:
define(ctx, 'procedure', childRule(ctx, P.RULE_identifier));
break;
case P.RULE_function_body:
define(ctx, 'function', childRule(ctx, P.RULE_identifier));
break;
case P.RULE_create_procedure_body:
define(ctx, 'procedure', childRule(ctx, P.RULE_procedure_name));
break;
case P.RULE_create_function_body:
define(ctx, 'function', childRule(ctx, P.RULE_function_name));
break;
case P.RULE_create_trigger: {
let triggerTable: string | undefined;
const findTable = (n: ParserRuleContext): boolean => {
if (n.ruleIndex === P.RULE_tableview_name) {
const parts = splitDotted(n.getText());
triggerTable = parts[parts.length - 1];
return true;
}
return n.children.some(
(c) =>
c instanceof ParserRuleContext &&
c.ruleIndex !== P.RULE_trigger_body &&
findTable(c),
);
};
findTable(ctx);
define(ctx, 'trigger', childRule(ctx, P.RULE_trigger_name), { triggerTable });
break;
}
case P.RULE_call_statement: {
const routines = childRules(ctx, P.RULE_routine_name);
const fnArgs = childRules(ctx, P.RULE_function_argument);
// `a.b(x).c(y)` chains are rare; the first routine_name is the callee.
const routine = routines[0];
if (!routine) break;
const raw = routine.getText();
const at = raw.indexOf('@');
const { arity, named } = argumentsOf(fnArgs[0]);
out.calls.push({
ownerPath: [...scope],
ownerIndex: ownerIndex(),
line: routine.start!.line,
column: routine.start!.column,
nameParts: splitDotted(at >= 0 ? raw.slice(0, at) : raw),
nameTokens: nameCtxs(routine).map(nameTokenOf),
arity: arity ?? 0,
namedArguments: named,
form: 'statement',
dbLink: at >= 0 ? raw.slice(at + 1).toUpperCase() : undefined,
});
break;
}
case P.RULE_general_element: {
// `pkg.fn(args)` / `fn(args)` in an expression. Only the outermost
// general_element; collection indexing `arr(i)` looks identical and
// is disambiguated later by name resolution against definitions.
if (ctx.parent?.ruleIndex === P.RULE_general_element) break;
const parts = flattenGeneralElement(ctx);
if (parts.length === 0) break;
const last = parts[parts.length - 1]!;
const fnArg = childRule(last, P.RULE_function_argument);
if (!fnArg) {
for (const p of parts) {
const id = childRule(p, P.RULE_id_expression);
if (id) out.expressionNames.push(nameTokenOf(id));
}
break;
}
const idCtxs = parts.map((p) => childRule(p, P.RULE_id_expression));
if (idCtxs.some((c) => c === undefined)) break;
const names = idCtxs.map((c) => normaliseIdentifier(c!.getText()));
const { arity, named } = argumentsOf(fnArg);
out.calls.push({
ownerPath: [...scope],
ownerIndex: ownerIndex(),
line: ctx.start!.line,
column: ctx.start!.column,
nameParts: names,
nameTokens: idCtxs.map((c) => nameTokenOf(c!)),
arity,
namedArguments: named,
form: 'expression',
});
break;
}
case P.RULE_tableview_name: {
const kind = accessKindOf(ctx);
if (!kind) break;
const raw = ctx.getText();
const at = raw.indexOf('@');
const parts = splitDotted(at >= 0 ? raw.slice(0, at) : raw);
const table = parts[parts.length - 1]!;
if (PSEUDO_TABLES.has(table)) break;
if (parts.length === 1 && cteNamesAbove(ctx).has(table)) break;
out.accesses.push({
ownerPath: [...scope],
ownerIndex: ownerIndex(),
line: ctx.start!.line,
column: ctx.start!.column,
table,
schema: parts.length > 1 ? parts[parts.length - 2] : undefined,
dbLink: at >= 0 ? raw.slice(at + 1).toUpperCase() : undefined,
kind,
});
break;
}
case P.RULE_execute_immediate: {
const expr = childRule(ctx, P.RULE_expression);
const text = expr?.getText() ?? '';
out.dynamicSql.push({
ownerPath: [...scope],
ownerIndex: ownerIndex(),
line: ctx.start!.line,
literal: /^'[\s\S]*'$/.test(text) ? text.slice(1, -1).replace(/''/g, "'") : undefined,
});
break;
}
}
},
exitEveryRule(ctx: ParserRuleContext) {
if (SCOPE_RULES.has(ctx.ruleIndex)) {
scope.pop();
owners.pop();
}
},
};
ParseTreeWalker.DEFAULT.walk(listener, segment.tree);
}
/** `a.b(x).c` → [a, b(x), c] general_element_part nodes in source order. */
function flattenGeneralElement(ctx: ParserRuleContext): ParserRuleContext[] {
const out: ParserRuleContext[] = [];
for (const c of ctx.children) {
if (!(c instanceof ParserRuleContext)) continue;
if (c.ruleIndex === P.RULE_general_element) out.push(...flattenGeneralElement(c));
else if (c.ruleIndex === P.RULE_general_element_part) out.push(c);
}
return out;
}
// ─── regex fallback ───────────────────────────────────────────────────────
const FALLBACK_UNIT =
/^[ \t]*create\s+(?:or\s+replace\s+)?(?:(?:non)?editionable\s+)?(package\s+body|package|procedure|function|trigger)\s+((?:"[^"]+"|[\w$#]+)(?:\.(?:"[^"]+"|[\w$#]+))?)/gim;
const FALLBACK_SUBPROGRAM = /^[ \t]*(procedure|function)\s+("[^"]+"|[\w$#]+)/gim;
/** Offset → line lookup built once per segment (binary search over line starts). */
function lineLocator(text: string, firstLine: number): (offset: number) => number {
const starts = [0];
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
return (offset) => {
let lo = 0;
let hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (starts[mid]! <= offset) lo = mid;
else hi = mid - 1;
}
return firstLine + lo;
};
}
/**
* Regex-level definitions for a segment whose tree is missing or erroneous.
* Only names the tree did not already yield are added.
*/
function fallbackDefinitions(segment: PlSqlSegment, known: ReadonlySet<string>): PlSqlDefinition[] {
const out: PlSqlDefinition[] = [];
const text = segment.text;
const lineAt = lineLocator(text, segment.startLine);
let unit: { name: string; kind: PlSqlDefKind; isSpec: boolean } | undefined;
for (const m of text.matchAll(FALLBACK_UNIT)) {
const kindText = m[1]!.toLowerCase().replace(/\s+/g, ' ');
const parts = splitDotted(m[2]!);
const kind: PlSqlDefKind = kindText.startsWith('package')
? 'package'
: (kindText as PlSqlDefKind);
const name = parts[parts.length - 1]!;
const line = lineAt(m.index!);
if (!known.has(`|${name}`)) {
out.push(
makeFallback(
kind,
name,
[],
line,
segment.endLine,
parts.length > 1 ? parts[0] : undefined,
kindText === 'package',
),
);
}
unit = { name, kind, isSpec: kindText === 'package' };
break; // one top-level unit per segment
}
if (unit?.kind !== 'package') return out;
// Nesting from `END <name>;` spans: a routine that starts inside another
// routine's span is local to it, not a package member. Spec declarations
// have no bodies, so everything in a spec is package-level.
const routines = [...text.matchAll(FALLBACK_SUBPROGRAM)].map((m) => {
const name = normaliseIdentifier(m[2]!);
let end = -1;
if (!unit!.isSpec) {
const label = m[2]!.startsWith('"') ? m[2]!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : name;
const close = new RegExp(
`^[ \\t]*end[ \\t]+${label.replace(/[$#]/g, '\\$&')}[ \\t]*;`,
'gim',
);
close.lastIndex = m.index!;
end = close.exec(text)?.index ?? -1;
}
return { name, kind: m[1]!.toLowerCase() as PlSqlDefKind, start: m.index!, end };
});
const pathOf = (i: number): string[] => {
const r = routines[i]!;
let parent = -1;
for (let j = 0; j < i; j++) {
const p = routines[j]!;
if (p.end > r.start && (parent < 0 || p.start > routines[parent]!.start)) parent = j;
}
return parent < 0 ? [unit!.name] : [...pathOf(parent), routines[parent]!.name];
};
routines.forEach((r, i) => {
const ownerPath = pathOf(i);
if (known.has(`${ownerPath.join('\0')}|${r.name}`)) return;
const line = lineAt(r.start);
out.push(makeFallback(r.kind, r.name, ownerPath, line, line, undefined, unit!.isSpec));
});
return out;
}
function makeFallback(
kind: PlSqlDefKind,
name: string,
ownerPath: string[],
startLine: number,
endLine: number,
schema: string | undefined,
isSpec: boolean,
): PlSqlDefinition {
return {
kind,
name,
schema,
ownerPath,
startLine,
startColumn: 0,
endLine,
endColumn: 0,
nameLine: startLine,
nameColumn: 0,
nameLength: name.length,
endLabels: [],
parameters: [],
isSpec,
declarationOnly: isSpec && kind !== 'package',
fromFallback: true,
};
}
// ─── entry point ──────────────────────────────────────────────────────────
export function extractPlSqlFacts(
source: string,
options: { segmentDeadlineMs?: number } = {},
): PlSqlFileFacts {
const parsed = parsePlSqlSource(source, options);
const out = {
definitions: [] as PlSqlDefinition[],
calls: [] as PlSqlCallSite[],
accesses: [] as PlSqlTableAccess[],
dynamicSql: [] as PlSqlDynamicSqlSite[],
expressionNames: [] as PlSqlNameToken[],
};
let segmentsWithErrors = 0;
let segmentsTimedOut = 0;
let firstError: string | undefined;
for (const segment of parsed.segments) {
const before = out.definitions.length;
extractSegment(segment, out);
if (segment.syntaxErrors > 0) {
segmentsWithErrors++;
if (segment.mode === 'timeout') segmentsTimedOut++;
firstError ??= segment.firstError;
const known = new Set(
out.definitions.slice(before).map((d) => `${d.ownerPath.join('\0')}|${d.name}`),
);
out.definitions.push(...fallbackDefinitions(segment, known));
}
}
return {
...out,
scriptIncludes: parsed.scriptIncludes,
sqlplusCommands: parsed.sqlplusCommands,
segments: parsed.segments.length,
segmentsWithErrors,
segmentsTimedOut,
firstError,
};
}
// ─── tokens for rename ────────────────────────────────────────────────────
export interface PlSqlLexedToken {
readonly text: string;
readonly line: number;
readonly column: number;
}
/**
* Default-channel tokens of pre-processed text (comments, whitespace and EOF
* excluded). Lives here so rename reaches the lexer through the lazily loaded
* parser module rather than importing it at start-up.
*/
export function lexPlSqlTokens(preprocessed: string): PlSqlLexedToken[] {
const lexer = new PlSqlLexer(CharStream.fromString(preprocessed));
lexer.removeErrorListeners();
const stream = new CommonTokenStream(lexer);
stream.fill();
return stream
.getTokens()
.filter((t) => t.channel === Token.DEFAULT_CHANNEL && t.type !== Token.EOF)
.map((t) => ({ text: t.text ?? '', line: t.line, column: t.column }));
}

View file

@ -0,0 +1,74 @@
/**
* Per-run store of extracted PL/SQL facts, and the lazy entry point to the
* ANTLR parser.
*
* The generated parser is large (~14 MB of JS, ~120 MB resident), so nothing
* that every analysis loads — the provider registry, parse workers, the scope
* resolver — imports it. The `plsql` phase calls `loadPlSqlParser()` only when
* the repository holds PL/SQL, and rename awaits it before parsing; other
* synchronous callers fall back to a synchronous load.
*
* Facts are keyed by path and content hash, so an edited or renamed source can
* never be served stale facts. The store holds one run: the processor resets
* it at the start of each run with room for every PL/SQL file, and other
* callers add misses under a small bounded budget. Facts are not persisted
* across runs (see PLSQL-07 in docs/languages/plsql-ingestion-gaps.md).
*/
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import type * as ExtractModule from './extract.js';
import type { PlSqlFileFacts, PlSqlLexedToken } from './extract.js';
type Extractor = typeof ExtractModule;
let extractor: Extractor | undefined;
/** Load the ANTLR parser and fact extractor; idempotent. */
export async function loadPlSqlParser(): Promise<void> {
extractor ??= await import('./extract.js');
}
function loadedExtractor(): Extractor {
if (extractor) return extractor;
try {
// Synchronous fallback for callers that cannot await (require(esm), Node >= 22.12).
extractor = createRequire(import.meta.url)('./extract.js') as Extractor;
} catch (cause) {
throw new Error('PL/SQL parser is not loaded: await loadPlSqlParser() before extracting', {
cause,
});
}
return extractor;
}
/** Head-room for extractions outside the phase (rename re-parses edited sources). */
const EXTRA_ENTRIES = 64;
let capacity = EXTRA_ENTRIES;
let store = new Map<string, PlSqlFileFacts>();
const keyOf = (filePath: string, content: string) =>
`${filePath}\0${createHash('sha1').update(content).digest('hex')}`;
/** Start a run sized for `fileCount` PL/SQL files; drops the previous run's facts. */
export function resetPlSqlFactsStore(fileCount: number): void {
store = new Map();
capacity = fileCount + EXTRA_ENTRIES;
}
/** Facts for `content` at `filePath`, extracted at most once per run. */
export function plsqlFactsForFile(filePath: string, content: string): PlSqlFileFacts {
const key = keyOf(filePath, content);
const cached = store.get(key);
if (cached) return cached;
const facts = loadedExtractor().extractPlSqlFacts(content);
if (store.size >= capacity) store.delete(store.keys().next().value!);
store.set(key, facts);
return facts;
}
/** Identifier-candidate tokens of pre-processed text, for rename. */
export function plsqlLexTokens(preprocessed: string): PlSqlLexedToken[] {
return loadedExtractor().lexPlSqlTokens(preprocessed);
}

View file

@ -0,0 +1,154 @@
/**
* Oracle PL/SQL file detection.
*
* Dedicated extensions are always PL/SQL. `.sql` is shared by every SQL
* dialect, so it is claimed only when the content contains an Oracle PL/SQL
* compilation unit (see `looksLikeOraclePlSql`).
*/
/** Extensions that are unambiguously Oracle PL/SQL source. */
export const PLSQL_EXTENSIONS = [
'.pks', // package spec
'.pkb', // package body
'.pck', // spec + body
'.pls', // generic PL/SQL source
'.plb', // body (often wrapped)
'.prc', // standalone procedure
'.fnc', // standalone function
'.trg', // trigger
'.tps', // object type spec
'.tpb', // object type body
] as const;
const PLSQL_EXTENSION_SET: ReadonlySet<string> = new Set(PLSQL_EXTENSIONS);
function extensionOf(filePath: string): string {
const slash = filePath.lastIndexOf('/');
const dot = filePath.lastIndexOf('.');
return dot > slash ? filePath.slice(dot).toLowerCase() : '';
}
export function hasPlSqlExtension(filePath: string): boolean {
return PLSQL_EXTENSION_SET.has(extensionOf(filePath));
}
export function isSqlFile(filePath: string): boolean {
return extensionOf(filePath) === '.sql';
}
/**
* An Oracle unit header at the start of a line:
* `CREATE [OR REPLACE] [EDITIONABLE|NONEDITIONABLE] PACKAGE|PROCEDURE|FUNCTION|TYPE <name> … IS|AS`
* `CREATE [OR REPLACE] … TRIGGER <name> BEFORE|AFTER|INSTEAD OF|FOR`
* `IS`/`AS` must follow the name (after an optional parameter list / RETURN
* clause) before any `BEGIN`, which rules out MySQL's `CREATE PROCEDURE p() BEGIN`.
* PostgreSQL's `AS $$` and T-SQL are excluded separately below.
*/
const ORACLE_UNIT_HEADER =
/^[ \t]*create\s+(?:or\s+replace\s+)?(?:(?:non)?editionable\s+)?(?:(?:package(?:\s+body)?|procedure|function|type(?:\s+body)?)\s+[\w$#."]+(?:(?!\bbegin\b)[\s\S]){0,4000}?\b(?:is|as)\b|trigger\s+[\w$#."]+\s+(?:before|after|instead\s+of|for)\b)/im;
const ORACLE_IS_HEADER =
/^[ \t]*create\s+(?:or\s+replace\s+)?(?:(?:non)?editionable\s+)?(?:package(?:\s+body)?|procedure|function|type(?:\s+body)?)\s+[\w$#."]+(?:(?!\b(?:begin|as)\b)[\s\S]){0,4000}?\bis\b/im;
/**
* `CREATE PACKAGE [BODY] <name> … IS|AS`: packages exist only in Oracle (and
* Oracle-compatible PL/SQL dialects) — T-SQL, PostgreSQL and MySQL have no
* `CREATE PACKAGE` — so a package header needs no further marker, even with `AS`.
*/
const ORACLE_PACKAGE_HEADER =
/^[ \t]*create\s+(?:or\s+replace\s+)?(?:(?:non)?editionable\s+)?package(?:\s+body)?\s+[\w$#."]+(?:(?!\bbegin\b)[\s\S]){0,4000}?\b(?:is|as)\b/im;
/**
* `CREATE OR REPLACE PROCEDURE|FUNCTION <name> … IS|AS`. T-SQL writes
* `CREATE OR ALTER`; MySQL/MariaDB, DB2 and BigQuery procedures go straight to
* `BEGIN`; PostgreSQL, Redshift and Snowflake declare a `LANGUAGE` or
* `RETURNS` and quote the body; a BigQuery SQL function writes `AS (expr)`.
* Without those, an `OR REPLACE` routine with `IS|AS` is Oracle even when its
* body is plain SQL. The header must not cross a `;`.
*/
const OR_REPLACE_ROUTINE_HEADER =
/^[ \t]*create\s+or\s+replace\s+(?:(?:non)?editionable\s+)?(?:procedure|function)\s+[\w$#."]+(?:(?!\bbegin\b)[^;]){0,4000}?\b(?:is|as)\b(?!\s*\()/im;
/** `LANGUAGE <x>` other than an Oracle Java/C call spec, or `RETURNS`. */
const NON_ORACLE_ROUTINE = /\breturns\b|\blanguage\s+(?!java\b|c\b)\w+/i;
/**
* A trigger whose body is a PL/SQL block. The header is shared with
* PostgreSQL and MySQL/MariaDB, so the dialects are told apart by what they
* cannot write: PostgreSQL triggers call `EXECUTE FUNCTION|PROCEDURE` instead
* of carrying a body, and a MySQL/MariaDB body can only hold `;` inside a
* `DELIMITER` script and refers to `NEW.col` / `OLD.col` without a colon.
*/
const TRIGGER_HEADER =
/^[ \t]*create\s+(?:or\s+replace\s+)?(?:(?:non)?editionable\s+)?trigger\s+[\w$#."]+\s+(?:before|after|instead\s+of|for)\b/im;
const PLSQL_BLOCK = /\b(?:begin|declare)\b[\s\S]*\bend\b[^;]*;/i;
const NON_ORACLE_TRIGGER =
/^\s*delimiter\b|`|(?<![:\w])(?:new|old)\.\w|\bexecute\s+(?:function|procedure)\b/im;
function looksLikeOracleTrigger(content: string): boolean {
return (
TRIGGER_HEADER.test(content) && PLSQL_BLOCK.test(content) && !NON_ORACLE_TRIGGER.test(content)
);
}
const POSTGRES_DOLLAR_BODY = /\bas\s+\$[\w]*\$|\blanguage\s+plpgsql\b/i;
const TSQL_MARKERS = /^\s*go\s*$|\bnvarchar\s*\(\s*max\s*\)|@@identity|\bset\s+nocount\s+on\b/im;
/**
* Constructs that only appear in Oracle PL/SQL — among them PL/SQL statement
* syntax T-SQL does not have (`:=`, `END IF;`, `ELSIF`, a `NULL;` statement,
* `EXCEPTION WHEN`, a function's `RETURN <type> IS|AS`), which is what tells an
* Oracle `CREATE PROCEDURE p AS …` from a T-SQL one. A trigger header alone is
* not one — PostgreSQL and MySQL write `CREATE TRIGGER t BEFORE …` too — so
* triggers are recognised by Oracle-only bodies: `:NEW.` / `:OLD.` bind
* references, `COMPOUND TRIGGER`, or the SQL*Plus `/` terminator.
*/
const ORACLE_MARKERS =
/%(?:row)?type\b|^\s*\/\s*$|\bpragma\s+(?:autonomous_transaction|exception_init|serially_reusable|restrict_references)\b|\bexecute\s+immediate\b|\bdbms_\w+\.|\braise_application_error\b|\bsys_refcursor\b|\bvarchar2\b|\bnvl\s*\(|:(?:new|old)\.|\bcompound\s+trigger\b|:=|\bend\s+(?:if|loop|case)\s*;|\belsif\b|(?:^|\bbegin|\bthen|\belse|\bloop|;)\s*null\s*;|\bexception\s+when\b|\breturn\s+[\w.%]+\s+(?:is|as)\b/im;
/**
* A SQL*Plus script inclusion of a PL/SQL or SQL file on its own line. `@@`,
* `@path.ext` and `START path.ext` are SQL*Plus client syntax (psql uses `\i`,
* sqlcmd `:r`), so install scripts made of them are Oracle sources too.
*/
const SQLPLUS_INCLUDE =
/^[ \t]*(?:@@|@|sta(?:rt)?[ \t]+)[\w./\\-]+\.(?:sql|pks|pkb|pck|pls|plb|prc|fnc|trg|tps|tpb)[ \t]*\r?$/im;
/**
* Comments, then string literals (`'…'` with `''` escapes, and `q'[…]'`
* alternative quoting), whichever starts first.
*/
const COMMENT_OR_STRING =
/--[^\n]*|\/\*[\s\S]*?(?:\*\/|$)|(?<![\w$#])[qQ]'(?:\[[\s\S]*?\]|\([\s\S]*?\)|\{[\s\S]*?\}|<[\s\S]*?>|(\S)[\s\S]*?\1)'|'(?:[^']|'')*(?:'|$)/g;
/**
* `content` with comments and string literals blanked, line breaks kept, so
* dialect evidence comes from code only: `-- :=` in a T-SQL comment or
* `'END IF;'` in a literal says nothing about the dialect.
*/
function codeOf(content: string): string {
return content.replace(COMMENT_OR_STRING, (text) => text.replace(/[^\n]/g, ' '));
}
/**
* True when `.sql` content holds at least one Oracle PL/SQL compilation unit,
* or is a SQL*Plus script that includes other scripts.
*/
export function looksLikeOraclePlSql(source: string): boolean {
const content = codeOf(source);
if (SQLPLUS_INCLUDE.test(content) && !TSQL_MARKERS.test(content)) return true;
if (!ORACLE_UNIT_HEADER.test(content)) return false;
if (POSTGRES_DOLLAR_BODY.test(content) || TSQL_MARKERS.test(content)) return false;
// `IS` after a unit header is Oracle-only syntax (T-SQL, PostgreSQL and
// MySQL write `AS` or `BEGIN`), so a plain `CREATE PROCEDURE p IS …` needs
// no further marker, nor does any package header or `OR REPLACE` routine;
// a plain `CREATE PROCEDURE|FUNCTION … AS` header (shared with T-SQL) needs
// PL/SQL syntax from ORACLE_MARKERS.
return (
ORACLE_IS_HEADER.test(content) ||
ORACLE_PACKAGE_HEADER.test(content) ||
(OR_REPLACE_ROUTINE_HEADER.test(content) && !NON_ORACLE_ROUTINE.test(content)) ||
looksLikeOracleTrigger(content) ||
ORACLE_MARKERS.test(content)
);
}
/** File-level predicate used by the PL/SQL phase. */
export function isPlSqlSource(filePath: string, content: string): boolean {
if (hasPlSqlExtension(filePath)) return true;
return isSqlFile(filePath) && looksLikeOraclePlSql(content);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
import { CommonToken, Lexer, CharStream, Token, CommonTokenStream } from "antlr4ng";
export abstract class PlSqlLexerBase extends Lexer {
IsNewlineAtPos(pos: number): boolean {
const la = this.inputStream.LA(pos);
return la == -1 || String.fromCharCode(la) == '\n';
}
IsQQuoteDelimiter(): boolean {
const opening = this.inputStream.LA(this.tokenStartCharIndex + 2 - this.inputStream.index);
const closing = this.inputStream.LA(-2);
switch (opening) {
case 91: return closing == 93;
case 123: return closing == 125;
case 60: return closing == 62;
case 40: return closing == 41;
default: return closing == opening;
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,117 @@
import { Parser, TokenStream, CommonTokenStream, Token } from 'antlr4ng';
import { PlSqlLexer } from './PlSqlLexer.js';
export abstract class PlSqlParserBase extends Parser {
_isVersion10: boolean;
_isVersion11: boolean;
_isVersion12: boolean;
_lastUnitWasPlsql: boolean;
constructor(input: TokenStream) {
super(input);
this._isVersion10 = true;
this._isVersion11 = true;
this._isVersion12 = true;
this._lastUnitWasPlsql = false;
}
reset(): void {
this._lastUnitWasPlsql = false;
super.reset();
}
setLastUnitPlsql(): void { this._lastUnitWasPlsql = true; }
setLastUnitSql(): void { this._lastUnitWasPlsql = false; }
isLastUnitSql(): boolean { return !this._lastUnitWasPlsql; }
isLastUnitPlsql(): boolean { return this._lastUnitWasPlsql; }
isSolidusSeparator(): boolean {
const stream = this.tokenStream as CommonTokenStream;
const solidus = stream.LT(1);
if (solidus == null || solidus!.type !== PlSqlLexer.SOLIDUS)
return false;
const solidusLine = solidus!.line;
const prev = stream.LT(-1);
if (prev != null && prev!.type !== Token.EOF && prev!.line === solidusLine)
return false;
const next = stream.LT(2);
if (next != null && next!.type !== Token.EOF && next!.line === solidusLine)
return false;
return true;
}
/** True when the current token is the first significant token on its line. */
isLineStart(): boolean {
const stream = this.tokenStream as CommonTokenStream;
const token = stream.LT(1);
if (token == null) {
return false;
}
const prev = stream.LT(-1);
if (prev == null || prev.type === Token.EOF) {
return true;
}
return prev.line < token.line;
}
isVersion10(): boolean {
return this._isVersion10;
}
isVersion11(): boolean {
return this._isVersion11;
}
isVersion12(): boolean {
return this._isVersion12;
}
setVersion10(value: boolean): void {
this._isVersion10 = value;
}
setVersion11(value: boolean): void {
this._isVersion11 = value;
}
setVersion12(value: boolean): void {
this._isVersion12 = value;
}
IsNotNumericFunction(): boolean {
const lt1 = (this.tokenStream as CommonTokenStream).LT(1);
const lt2 = (this.tokenStream as CommonTokenStream).LT(2);
if ((lt1!.type === PlSqlLexer.SUM ||
lt1!.type === PlSqlLexer.COUNT ||
lt1!.type === PlSqlLexer.AVG ||
lt1!.type === PlSqlLexer.MIN ||
lt1!.type === PlSqlLexer.MAX ||
lt1!.type === PlSqlLexer.ROUND ||
lt1!.type === PlSqlLexer.LEAST ||
lt1!.type === PlSqlLexer.GREATEST) && lt2!.type === PlSqlLexer.LEFT_PAREN)
return false;
return true;
}
isNotStartOfJoin(): boolean {
const lt1 = (this.tokenStream as CommonTokenStream).LT(1);
if (lt1!.type == PlSqlLexer.INNER ||
lt1!.type == PlSqlLexer.CROSS ||
lt1!.type == PlSqlLexer.NATURAL ||
lt1!.type == PlSqlLexer.PARTITION ||
lt1!.type == PlSqlLexer.FULL ||
lt1!.type == PlSqlLexer.LEFT ||
lt1!.type == PlSqlLexer.RIGHT ||
lt1!.type == PlSqlLexer.OUTER)
return false;
return true;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
# Generated Oracle PL/SQL parser — do not edit
`PlSqlLexer.ts`, `PlSqlParser.ts` and `PlSqlParserListener.ts` are generated by
[antlr-ng](https://www.npmjs.com/package/antlr-ng) 1.0.10 for the
[antlr4ng](https://www.npmjs.com/package/antlr4ng) runtime from the grammars in
`../grammar/`. They are committed, so a clean checkout builds with plain `tsc`
and no Java or ANTLR tool at build time. Regenerate after changing the grammar
with:
```sh
cd gitnexus && node scripts/generate-plsql-parser.mjs
```
| Input | Source | License |
| ----------------------------- | ---------------------------------------------------------------------------------------------- | ---------- |
| `../grammar/PlSql*.g4` | [antlr/grammars-v4](https://github.com/antlr/grammars-v4/tree/master/sql/plsql) `sql/plsql` at `2766c2eed04f285dc763617c9459650892fcf5b9` (2026-10-28) | Apache-2.0 |
| `PlSqlLexerBase.ts`, `PlSqlParserBase.ts` | same commit, `sql/plsql/Antlr4ng/` (hand-written base classes the grammar's actions call) | Apache-2.0 |
Lint and formatting skip this directory (`eslint.config.mjs`, `.prettierignore`).
Do not patch generated files; fix the grammar upstream or in `../grammar/` and regenerate.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
/**
* Oracle identifier normalisation, kept free of the ANTLR runtime so the
* provider, resolver and rename modules can use it without loading the parser.
*/
/** Unquoted identifiers are upper-case; quoted identifiers keep their spelling without quotes. */
export function normaliseIdentifier(text: string): string {
return text.startsWith('"') && text.endsWith('"') && text.length >= 2
? text.slice(1, -1)
: text.toUpperCase();
}

View file

@ -0,0 +1,318 @@
/**
* ANTLR4 driver for the grammars-v4 PlSql grammar.
*
* - Source is pre-processed (SQL*Plus, conditional compilation) without moving
* any line, then split into segments at lone `/` terminator lines, so a
* syntax error or timeout is confined to one compilation unit.
* - Each segment is parsed with SLL + bail first (fast path) and re-parsed
* with full LL + error recovery only when SLL fails.
* - A per-segment deadline aborts pathological inputs; callers fall back to
* regex-level facts for that segment.
*
* Segments are parsed as the original text prefixed with blank lines, so token
* line numbers are file line numbers.
*/
import {
BailErrorStrategy,
BaseErrorListener,
CharStream,
CommonTokenStream,
DefaultErrorStrategy,
ListTokenSource,
Token,
ParseCancellationException,
PredictionMode,
type ATNSimulator,
type ParseTreeListener,
type ParserRuleContext,
type RecognitionException,
type Recognizer,
} from 'antlr4ng';
import { PlSqlLexer } from './generated/PlSqlLexer.js';
import { PlSqlParser, type Sql_scriptContext } from './generated/PlSqlParser.js';
import {
preprocessPlSql,
scriptIncludesOf,
type ScriptInclude,
type SqlPlusCommand,
} from './preprocess.js';
/**
* Wall-clock budget for one segment: a floor plus a per-line allowance.
* A cold ANTLR DFA cache needs ~5 ms/line on large package bodies (a valid
* 3.4k-line body took ~18 s in the spike), so a flat budget would drop the
* largest — usually most important — packages to the regex fallback.
* `GITNEXUS_PLSQL_SEGMENT_DEADLINE_MS` overrides the floor; 0 disables.
*/
export const SEGMENT_DEADLINE_FLOOR_MS = 10_000;
export const SEGMENT_DEADLINE_PER_LINE_MS = 15;
export function segmentDeadlineMs(lines: number, floorMs = configuredFloorMs()): number {
if (floorMs <= 0) return Number.POSITIVE_INFINITY;
return Math.max(floorMs, lines * SEGMENT_DEADLINE_PER_LINE_MS);
}
function configuredFloorMs(): number {
const raw = process.env.GITNEXUS_PLSQL_SEGMENT_DEADLINE_MS;
const value = raw === undefined || raw.trim() === '' ? NaN : Number(raw);
return Number.isFinite(value) && value >= 0 ? value : SEGMENT_DEADLINE_FLOOR_MS;
}
export interface PlSqlSegment {
/** 1-based first and last source line of the segment. */
readonly startLine: number;
readonly endLine: number;
/** Pre-processed segment text (same line layout as the source). */
readonly text: string;
/** Parse tree; undefined when the segment timed out. */
readonly tree?: Sql_scriptContext;
readonly syntaxErrors: number;
readonly firstError?: string;
readonly mode: 'sll' | 'll' | 'timeout';
}
export interface ParsedPlSqlSource {
readonly segments: readonly PlSqlSegment[];
readonly scriptIncludes: readonly ScriptInclude[];
/** SQL*Plus client commands blanked by the pre-pass (EXEC, SET, @@, …). */
readonly sqlplusCommands: readonly SqlPlusCommand[];
}
class CountingErrorListener extends BaseErrorListener {
count = 0;
first?: string;
override syntaxError<S extends Token, T extends ATNSimulator>(
_recognizer: Recognizer<T>,
_offendingSymbol: S | null,
line: number,
column: number,
msg: string,
_e: RecognitionException | null,
): void {
this.count++;
this.first ??= `${line}:${column} ${msg.slice(0, 160)}`;
}
}
class DeadlineExceeded extends Error {}
interface DeadlineGuard {
/** Throws once the deadline has passed; reads the clock on every 256th call to stay cheap. */
tick(): void;
/** Throws once the deadline has passed; always reads the clock. */
now(): void;
}
function deadlineGuard(deadline: number): DeadlineGuard {
let ticks = 0;
const now = () => {
if (performance.now() > deadline) throw new DeadlineExceeded();
};
return {
tick: () => {
if ((++ticks & 255) === 0) now();
},
now,
};
}
/** Parse listener that runs the guard on every rule entry and every consumed token. */
function deadlineListener(guard: DeadlineGuard): ParseTreeListener {
return {
visitTerminal: () => guard.tick(),
visitErrorNode() {},
enterEveryRule: () => guard.tick(),
exitEveryRule() {},
};
}
/**
* Tokens of `text`, lexed once under the deadline (SLL and LL re-use them).
* Lexer errors (characters no token rule matches) are counted: the lexer
* skips them and the parser may then succeed, so they must still mark the
* segment as degraded.
*/
function lex(
text: string,
guard: DeadlineGuard,
): { tokens: Token[]; lexerErrors: CountingErrorListener } {
const lexer = new PlSqlLexer(CharStream.fromString(text));
lexer.removeErrorListeners();
const lexerErrors = new CountingErrorListener();
lexer.addErrorListener(lexerErrors);
const tokens: Token[] = [];
for (;;) {
guard.tick();
const token = lexer.nextToken();
tokens.push(token);
if (token.type === Token.EOF) break;
}
return { tokens, lexerErrors };
}
/**
* Parser over pre-lexed tokens with the deadline checked on rule entry, on
* every consumed token and around every adaptive prediction. A single
* prediction cannot be interrupted (ANTLR exposes no hook inside one), so the
* clock is read unthrottled right after each one: a prediction that overran
* the budget ends the parse there instead of being followed by a result.
*/
function newParser(tokens: Token[], guard: DeadlineGuard): PlSqlParser {
const parser = new PlSqlParser(new CommonTokenStream(new ListTokenSource(tokens)));
parser.removeErrorListeners();
parser.addParseListener(deadlineListener(guard));
const interpreter = parser.interpreter;
const predict = interpreter.adaptivePredict.bind(interpreter);
interpreter.adaptivePredict = (...args: Parameters<typeof predict>) => {
guard.tick();
const alt = predict(...args);
guard.now();
return alt;
};
return parser;
}
function parseSegment(
text: string,
deadlineMs: number,
): Omit<PlSqlSegment, 'startLine' | 'endLine' | 'text'> {
const guard = deadlineGuard(performance.now() + deadlineMs);
try {
const { tokens, lexerErrors } = lex(text, guard);
const sll = newParser(tokens, guard);
sll.interpreter.predictionMode = PredictionMode.SLL;
sll.errorHandler = new BailErrorStrategy();
try {
const tree = sll.sql_script();
// A tree finished past the budget is still a timeout.
guard.now();
return { tree, syntaxErrors: lexerErrors.count, firstError: lexerErrors.first, mode: 'sll' };
} catch (e) {
if (!(e instanceof ParseCancellationException)) throw e;
}
const ll = newParser(tokens, guard);
const errors = new CountingErrorListener();
ll.addErrorListener(errors);
ll.interpreter.predictionMode = PredictionMode.LL;
ll.errorHandler = new DefaultErrorStrategy();
const tree = ll.sql_script();
guard.now();
return {
tree,
syntaxErrors: errors.count + lexerErrors.count,
firstError: lexerErrors.first ?? errors.first,
mode: 'll',
};
} catch (e) {
if (e instanceof DeadlineExceeded)
return { syntaxErrors: 1, firstError: 'deadline exceeded', mode: 'timeout' };
throw e;
}
}
/**
* Lexical state carried from one line to the next while looking for `/`
* terminators: a lone `/` inside a block comment or a (multi-line) string
* literal is text, not a terminator.
*/
type CarryState =
| { kind: 'code' }
| { kind: 'block-comment' }
| { kind: 'string' }
| { kind: 'q-string'; close: string };
const Q_CLOSE: Record<string, string> = { '[': ']', '(': ')', '{': '}', '<': '>' };
/** Scan one line from `state`; returns the state at the end of the line. */
function scanLine(line: string, state: CarryState): CarryState {
let i = 0;
let st = state;
while (i < line.length) {
if (st.kind === 'block-comment') {
const end = line.indexOf('*/', i);
if (end < 0) return st;
i = end + 2;
st = { kind: 'code' };
} else if (st.kind === 'string') {
const end = line.indexOf("'", i);
if (end < 0) return st;
if (line[end + 1] === "'") {
i = end + 2; // '' escape
continue;
}
i = end + 1;
st = { kind: 'code' };
} else if (st.kind === 'q-string') {
const end = line.indexOf(`${st.close}'`, i);
if (end < 0) return st;
i = end + 2;
st = { kind: 'code' };
} else {
const ch = line[i]!;
const next = line[i + 1];
if (ch === '-' && next === '-') return st; // line comment
if (ch === '/' && next === '*') {
st = { kind: 'block-comment' };
i += 2;
} else if ((ch === 'q' || ch === 'Q') && next === "'" && !/[\w$#]/.test(line[i - 1] ?? '')) {
const open = line[i + 2] ?? '';
st = { kind: 'q-string', close: Q_CLOSE[open] ?? open };
i += 3;
} else if (ch === "'") {
st = { kind: 'string' };
i += 1;
} else i += 1;
}
}
return st;
}
/**
* Split pre-processed text at lone `/` terminator lines; each segment keeps
* its line offset. A `/` line inside a block comment or a string literal does
* not split.
*/
export function splitSegments(
text: string,
): Array<{ startLine: number; endLine: number; body: string }> {
const lines = text.split('\n');
const out: Array<{ startLine: number; endLine: number; body: string }> = [];
let start = 0;
const flush = (endExclusive: number) => {
const body = lines.slice(start, endExclusive).join('\n');
if (body.trim() !== '') out.push({ startLine: start + 1, endLine: endExclusive, body });
};
let state: CarryState = { kind: 'code' };
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (state.kind === 'code' && line.trim() === '/') {
flush(i + 1);
start = i + 1;
continue;
}
state = scanLine(line, state);
}
flush(lines.length);
return out;
}
export function parsePlSqlSource(
source: string,
/** `segmentDeadlineMs` replaces the size-scaled budget (tests). */
options: { segmentDeadlineMs?: number } = {},
): ParsedPlSqlSource {
const commands: SqlPlusCommand[] = [];
const text = preprocessPlSql(source, commands);
const segments: PlSqlSegment[] = [];
for (const seg of splitSegments(text)) {
// Prefix blank lines so token lines are file lines.
const padded = '\n'.repeat(seg.startLine - 1) + seg.body;
const lines = seg.endLine - seg.startLine + 1;
const result = parseSegment(padded, options.segmentDeadlineMs ?? segmentDeadlineMs(lines));
segments.push({ startLine: seg.startLine, endLine: seg.endLine, text: seg.body, ...result });
}
return { segments, scriptIncludes: scriptIncludesOf(commands), sqlplusCommands: commands };
}
export type { ParserRuleContext };

View file

@ -0,0 +1,280 @@
/**
* Line-preserving PL/SQL source pre-pass.
*
* The grammar recognises SQL and PL/SQL units; real repositories also carry
* SQL*Plus client commands (`SET`, `DEFINE`, `@@file`, `PROMPT`, `-`
* continuations, `&` substitution variables) and PL/SQL conditional
* compilation (`$IF ... $THEN ... $END`). Both are rewritten here so the parser
* sees only compilable text. Every transformation keeps line breaks — and
* blanks in place rather than deleting — so ANTLR token positions are the
* original source positions.
*/
const SQLPLUS_COMMAND =
/^\s*(?:set(?!\s+(?:transaction|role|constraints?)\b)|define|def|undefine|undef|prompt|pro|rem|remark|spool|spo|whenever|column|col|connect|conn|accept|acc|pause|show|sho|exit|quit|host|ttitle|btitle|break|compute|clear|var|variable|print|timing|exec|execute|start|sta|disconnect|disc|store|repheader|repfooter|attribute|copy|describe|desc|get|help|list|password|run|save|xquery)\b/i;
/**
* Statements that start a PL/SQL unit, which only a lone `/` terminates.
* Tested against a header joined across lines, so `CREATE OR REPLACE` on its
* own line followed by `PACKAGE BODY …` is still recognised.
*/
const PLSQL_UNIT_START =
/^(?:create\s+(?:or\s+replace\s+)?(?:(?:non)?editionable\s+)?(?:and\s+(?:compile|resolve)\s+(?:noforce\s+)?java\b|package|procedure|function|trigger|type|library)\b|declare\b|begin\b)/i;
/** A `CREATE` whose object keyword may still be on a following line. */
const CREATE_PREFIX = /^create\b/i;
/** Lines looked ahead to complete a split `CREATE` header. */
const HEADER_LOOKAHEAD_LINES = 6;
/** `;` at line end, optionally followed by closed block comments and/or a `--` comment. */
const ENDS_STATEMENT = /;\s*(?:\/\*.*?\*\/\s*)*(?:--.*)?$/;
const CONTINUATION = /\s-\s*$/;
const LEADING_BLOCK_COMMENTS = /^(?:\/\*[\s\S]*?\*\/\s*)+/;
export interface SqlPlusCommand {
/** 1-based line of the command's first line. */
readonly line: number;
readonly text: string;
}
/** `CREATE` header text joined with the next few code lines (comments skipped). */
function joinedHeader(lines: readonly string[], index: number, first: string): string {
const parts = [first];
for (let j = index + 1; j < lines.length && parts.length <= HEADER_LOOKAHEAD_LINES; j++) {
const t = lines[j]!.replace(/\r$/, '').trim().replace(LEADING_BLOCK_COMMENTS, '');
if (t === '' || t.startsWith('--')) continue;
parts.push(t);
}
return parts.join(' ');
}
/**
* Blank SQL*Plus commands. They are only recognised *between* statements:
* inside a PL/SQL unit `EXECUTE IMMEDIATE ...`, `EXIT WHEN …` or a call to a
* procedure named `run` is ordinary code and must survive.
*/
export function stripSqlPlusCommands(source: string, commands?: SqlPlusCommand[]): string {
const lines = source.split('\n');
let state: 'between' | 'plsql' | 'sql' = 'between';
let continuation = false;
let inBlockComment = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const trimmed = line.replace(/\r$/, '').trim();
if (continuation) {
continuation = CONTINUATION.test(trimmed);
lines[i] = blankLine(line);
continue;
}
if (inBlockComment) {
if (trimmed.includes('*/')) inBlockComment = false;
continue;
}
if (state === 'plsql') {
if (trimmed === '/') state = 'between';
continue;
}
if (state === 'sql') {
if (trimmed === '/' || ENDS_STATEMENT.test(trimmed)) state = 'between';
continue;
}
// Code after any leading `/* … */` on the same line.
const code = trimmed.replace(LEADING_BLOCK_COMMENTS, '');
if (code.startsWith('/*')) {
inBlockComment = true;
continue;
}
if (code === '' || code.startsWith('--') || code === '/') continue;
if (code === trimmed && (SQLPLUS_COMMAND.test(code) || code.startsWith('@'))) {
continuation = CONTINUATION.test(code);
commands?.push({ line: i + 1, text: line.replace(/\r$/, '') });
lines[i] = blankLine(line);
continue;
}
const header = CREATE_PREFIX.test(code) ? joinedHeader(lines, i, code) : code;
if (PLSQL_UNIT_START.test(header)) state = 'plsql';
else if (!ENDS_STATEMENT.test(code)) state = 'sql';
}
return lines.join('\n');
}
function blankLine(line: string): string {
return line.endsWith('\r') ? '\r' : '';
}
/**
* `&name` / `&&name` / `&name.` / positional `&1` → an identifier of exactly
* the same length (`Zname`, `ZZname`, `Z1`), so token columns stay aligned
* with the original text.
*/
export function neutraliseSubstitutionVariables(source: string): string {
return source.replace(
/(&&?)([A-Za-z_][\w$#]*|\d+)(\.?)/g,
(_m, amps: string, name: string, dot: string) =>
`${'Z'.repeat(amps.length)}${name}${dot ? ' ' : ''}`,
);
}
type CcFrame = { mode: 'condition' | 'kept' | 'skipped' | 'error'; taken: boolean };
const CC_DIRECTIVE = /^\$(if|then|elsif|else|end|error)\b/i;
/**
* Resolve conditional compilation by keeping the first selected branch of
* every `$IF` (the condition is not evaluated) and blanking directives, the
* conditions and all other branches. Nesting-aware; strings, quoted
* identifiers and comments are skipped so `$end` inside a literal is ignored.
*/
export function resolveConditionalCompilation(source: string): string {
if (!/\$(?:if|error)\b/i.test(source)) return source;
const out: string[] = [];
const stack: CcFrame[] = [];
const blanking = () => stack.some((f) => f.mode !== 'kept');
const emit = (text: string) => {
out.push(blanking() ? text.replace(/[^\r\n]/g, ' ') : text);
};
const emitBlank = (text: string) => out.push(text.replace(/[^\r\n]/g, ' '));
let i = 0;
while (i < source.length) {
const ch = source[i]!;
const next = source[i + 1];
// -- line comment
if (ch === '-' && next === '-') {
const end = source.indexOf('\n', i);
const stop = end === -1 ? source.length : end;
emit(source.slice(i, stop));
i = stop;
continue;
}
// /* block comment */
if (ch === '/' && next === '*') {
const end = source.indexOf('*/', i + 2);
const stop = end === -1 ? source.length : end + 2;
emit(source.slice(i, stop));
i = stop;
continue;
}
// q'[...]' alternative quoting
if (
(ch === 'q' || ch === 'Q') &&
next === "'" &&
i + 2 < source.length &&
!isWordChar(source[i - 1])
) {
const open = source[i + 2]!;
const close = { '[': ']', '(': ')', '{': '}', '<': '>' }[open] ?? open;
const end = source.indexOf(`${close}'`, i + 3);
const stop = end === -1 ? source.length : end + 2;
emit(source.slice(i, stop));
i = stop;
continue;
}
// '...' string with '' escapes
if (ch === "'") {
let j = i + 1;
while (j < source.length) {
if (source[j] === "'") {
if (source[j + 1] === "'") j += 2;
else break;
} else j++;
}
const stop = Math.min(j + 1, source.length);
emit(source.slice(i, stop));
i = stop;
continue;
}
// "quoted identifier"
if (ch === '"') {
const end = source.indexOf('"', i + 1);
const stop = end === -1 ? source.length : end + 1;
emit(source.slice(i, stop));
i = stop;
continue;
}
if (ch === '$' && next !== '$' && !isWordChar(source[i - 1])) {
const m = CC_DIRECTIVE.exec(source.slice(i, i + 8));
if (m) {
const directive = m[1]!.toLowerCase();
const top = stack[stack.length - 1];
switch (directive) {
case 'if':
stack.push({ mode: 'condition', taken: false });
break;
case 'error':
stack.push({ mode: 'error', taken: false });
break;
case 'then':
if (top?.mode === 'condition') {
top.mode = top.taken ? 'skipped' : 'kept';
top.taken = true;
}
break;
case 'elsif':
if (top && top.mode !== 'error') top.mode = 'condition';
break;
case 'else':
if (top && top.mode !== 'error') {
top.mode = top.taken ? 'skipped' : 'kept';
top.taken = true;
}
break;
case 'end':
emitBlank(m[0]);
stack.pop();
i += m[0].length;
continue;
}
emitBlank(m[0]);
i += m[0].length;
continue;
}
}
emit(ch);
i++;
}
return out.join('');
}
function isWordChar(ch: string | undefined): boolean {
return ch !== undefined && /[\w$#]/.test(ch);
}
/** Full pre-pass in the order the constructs nest. */
export function preprocessPlSql(source: string, commands?: SqlPlusCommand[]): string {
return resolveConditionalCompilation(
neutraliseSubstitutionVariables(stripSqlPlusCommands(source, commands)),
);
}
export interface ScriptInclude {
readonly line: number;
/** 0-based column of the path text. */
readonly column: number;
/** Path as written (`pkg_loan.pks`, `../common/setup`). */
readonly target: string;
/** `@@` resolves against the calling script's directory; `@`/`START` against the working directory. */
readonly relativeToScript: boolean;
}
const INCLUDE_COMMAND = /^(\s*)(@@|@|sta(?:rt)?\s+)("[^"]+"|\S+)/i;
/** SQL*Plus script inclusion (`@@file`, `@file`, `START file`) among blanked commands. */
export function scriptIncludesOf(commands: readonly SqlPlusCommand[]): ScriptInclude[] {
const out: ScriptInclude[] = [];
for (const command of commands) {
const m = INCLUDE_COMMAND.exec(command.text);
if (!m) continue;
const raw = m[3]!;
const target = raw.startsWith('"') ? raw.slice(1, -1) : raw;
// Substitution-variable paths (`@&dir/x.sql`) cannot be resolved statically.
if (target.includes('&')) continue;
out.push({
line: command.line,
column: m[1]!.length + m[2]!.length + (raw.startsWith('"') ? 1 : 0),
target,
relativeToScript: m[2] === '@@',
});
}
return out;
}

View file

@ -0,0 +1,342 @@
/**
* PL/SQL name resolution shared by the graph processor and the scope resolver,
* so structural CALLS edges and rename's source bindings make the same decision.
*
* Oracle resolves a subprogram name innermost-first:
* NAME → nested/sibling subprograms up the enclosing chain, then a
* standalone procedure/function.
* A.NAME → member NAME of package A; else standalone NAME in schema A.
* S.A.NAME → member NAME of package A in schema S.
* Overloads are narrowed by arity (counting defaulted parameters) and named
* arguments; what remains is returned as-is so callers can report ambiguity.
*
* Names are global in a database schema but not in a repository: the same
* package often exists several times (release folders, rollback scripts,
* fixtures). Ties between same-named definitions in different files are
* therefore broken by path proximity to the caller, and a package spec is
* paired with the nearest body (`PlSqlPackageInstances`).
*/
import type { PlSqlCallSite, PlSqlDefinition } from './extract.js';
export interface PlSqlIndexEntry {
readonly def: PlSqlDefinition;
readonly filePath: string;
/** Schema written on the enclosing package (`CREATE PACKAGE loan.pkg`), if any. */
readonly packageSchema?: string;
/**
* Package instance (`PlSqlPackageInstances.instanceOf`) of a package member,
* so a spec declaration and its body — often in different folders — are
* weighed as one entity when proximity picks between copies.
*/
instance?: string;
}
/**
* The package a definition belongs to: the root of its `ownerIndex` chain,
* or — for fallback definitions without an index — the first package of that
* name among `fileDefs`.
*
* `fileDefs` must be the definitions of the file `def` was extracted from (one
* `PlSqlFileFacts.definitions` array): `ownerIndex` indexes into it, and the
* fallback lookup is then same-file by construction.
*/
export function rootPackageOf(
def: PlSqlDefinition,
fileDefs: readonly PlSqlDefinition[],
): PlSqlDefinition | undefined {
if (def.kind === 'package') return def;
let current: PlSqlDefinition | undefined = def;
while (current?.ownerIndex !== undefined) current = fileDefs[current.ownerIndex];
if (current && current !== def && current.kind === 'package') return current;
const pkg = def.ownerPath[0];
return pkg === undefined
? undefined
: fileDefs.find((d) => d.kind === 'package' && d.name === pkg);
}
/** NUL-separated: a quoted identifier may itself contain `.` (`"A.B"` ≠ `A`.`B`). */
const pathKey = (path: readonly string[]) => path.join('\0');
/** Join name parts with `.`, re-quoting any part that itself contains a dot. */
export function dottedName(parts: readonly string[]): string {
return parts.map((p) => (p.includes('.') ? `"${p}"` : p)).join('.');
}
/**
* Owner path + name + parameter signature: identifies a spec declaration with
* its body. Oracle requires the two headings to match word for word apart from
* white space (PLS-00323 otherwise), so parameter names are part of the key —
* which also keeps apart overloads that differ only in parameter names, told
* apart by named-argument calls. Type text is whitespace-free and upper-cased.
* See the Oracle 19c PL/SQL Language Reference, "Package Body"
* (lnpls/package-body.html) and "Overloaded Subprograms"
* (lnpls/overloaded-subprograms.html).
*/
export function plsqlSignatureKey(
def: Pick<PlSqlDefinition, 'ownerPath' | 'name' | 'parameters'>,
): string {
return `${dottedName([...def.ownerPath, def.name])}(${def.parameters.map((p) => `${p.name} ${p.type}`.trim()).join(',')})`;
}
/** Number of leading directory segments two repository paths share. */
export function sharedDirectoryDepth(a: string, b: string): number {
const da = a.split('/').slice(0, -1);
const db = b.split('/').slice(0, -1);
let depth = 0;
while (depth < da.length && depth < db.length && da[depth] === db[depth]) depth++;
return depth;
}
/** Keep only the candidates whose file is nearest to `fromFile`. */
export function nearestByPath<E extends { readonly filePath: string }>(
candidates: readonly E[],
fromFile: string,
): E[] {
if (candidates.length <= 1) return [...candidates];
const depths = candidates.map((c) =>
c.filePath === fromFile ? Number.MAX_SAFE_INTEGER : sharedDirectoryDepth(c.filePath, fromFile),
);
const best = Math.max(...depths);
return candidates.filter((_, i) => depths[i] === best);
}
export class PlSqlNameIndex<E extends PlSqlIndexEntry> {
private readonly members = new Map<string, Map<string, E[]>>();
add(entry: E): void {
const owner = pathKey(entry.def.ownerPath);
let byName = this.members.get(owner);
if (!byName) this.members.set(owner, (byName = new Map()));
const list = byName.get(entry.def.name);
if (list) list.push(entry);
else byName.set(entry.def.name, [entry]);
}
lookup(ownerPath: readonly string[], name: string): E[] {
return this.members.get(pathKey(ownerPath))?.get(name) ?? [];
}
/** Candidate definitions for a call, before overload narrowing. */
candidates(call: Pick<PlSqlCallSite, 'nameParts' | 'ownerPath'>): E[] {
const parts = call.nameParts;
const callable = (e: E) => e.def.kind === 'procedure' || e.def.kind === 'function';
if (parts.length === 1) {
const name = parts[0]!;
for (let depth = call.ownerPath.length; depth >= 0; depth--) {
const found = this.lookup(call.ownerPath.slice(0, depth), name).filter(callable);
if (found.length > 0) return found;
}
return [];
}
const name = parts[parts.length - 1]!;
const qualifier = parts[parts.length - 2]!;
const member = this.lookup([qualifier], name).filter(callable);
if (member.length > 0) return member;
if (parts.length === 2) {
// `S.NAME`: a standalone subprogram in schema S. Definitions that wrote
// another schema are excluded; ones that wrote none (current schema) stay
// candidates, behind any that name S explicitly.
return inWrittenSchema(
this.lookup([], name).filter(callable),
qualifier,
(e) => e.def.schema,
);
}
return [];
}
/**
* Resolve a call written in `fromFile` to its implementation(s): overloads,
* then path proximity, then spec/forward declaration vs body. Empty when
* nothing in the repository matches.
*/
resolve(
call: Pick<PlSqlCallSite, 'nameParts' | 'ownerPath' | 'arity' | 'namedArguments'>,
fromFile: string,
): E[] {
const narrowed = preferWrittenSchema(narrowOverloads(this.candidates(call), call), call);
return preferImplementations(nearestEntities(narrowed, fromFile));
}
}
/**
* Proximity over entities, not entries: a spec declaration and its body (same
* package instance and signature) form one entity whose distance is that of
* its nearest member, so a body in another folder is not discarded because
* the caller sits next to the spec. Standalone subprograms are one entity per
* file.
*/
function nearestEntities<E extends PlSqlIndexEntry>(candidates: E[], fromFile: string): E[] {
return nearestGroups<E>(candidates, fromFile, entityKey);
}
/** One subprogram of one package instance (or of one standalone file). */
function entityKey(e: PlSqlIndexEntry): string {
return `${e.instance ?? e.filePath}\0${plsqlSignatureKey(e.def)}`;
}
/**
* Keep the candidates of the nearest group(s), where a group (all entries
* sharing `keyOf`) is as near to `fromFile` as its nearest member.
*/
export function nearestGroups<E extends { readonly filePath: string }>(
candidates: readonly E[],
fromFile: string,
keyOf: (candidate: E) => string,
): E[] {
if (candidates.length <= 1) return [...candidates];
const groups = new Map<string, E[]>();
for (const c of candidates) {
const key = keyOf(c);
const group = groups.get(key);
if (group) group.push(c);
else groups.set(key, [c]);
}
const distance = (group: E[]) =>
Math.max(
...group.map((c) =>
c.filePath === fromFile
? Number.MAX_SAFE_INTEGER
: sharedDirectoryDepth(c.filePath, fromFile),
),
);
const scored = [...groups.values()].map((g) => ({ g, d: distance(g) }));
const best = Math.max(...scored.map((s) => s.d));
return scored.filter((s) => s.d === best).flatMap((s) => s.g);
}
/** In `S.A.N`, only members of package `A` that may live in schema `S`. */
function preferWrittenSchema<E extends PlSqlIndexEntry>(
candidates: E[],
call: Pick<PlSqlCallSite, 'nameParts'>,
): E[] {
if (call.nameParts.length !== 3) return candidates;
return inWrittenSchema(candidates, call.nameParts[0], (c) => c.packageSchema);
}
/**
* Candidates a reference qualified by schema `schema` can denote: those
* declared in that schema when any are indexed, otherwise those declared
* without a schema (created in whatever schema ran the script). A definition
* that names another schema never qualifies. No written schema keeps all.
*/
export function inWrittenSchema<E>(
candidates: readonly E[],
schema: string | undefined,
schemaOf: (candidate: E) => string | undefined,
): E[] {
if (schema === undefined) return [...candidates];
const exact = candidates.filter((c) => schemaOf(c) === schema);
return exact.length > 0 ? exact : candidates.filter((c) => schemaOf(c) === undefined);
}
/**
* Keep the candidates a call can bind to. With a known argument list, a
* candidate fits when the positional arguments fill its leading parameters,
* every named argument names one of the remaining parameters, and every
* parameter left unfilled has a default. A known mismatch is dropped — even a
* sole candidate — so it yields no edge rather than a wrong one. Candidates
* with unknown parameters (regex fallback) always fit.
*/
function narrowOverloads<E extends PlSqlIndexEntry>(
candidates: E[],
call: Pick<PlSqlCallSite, 'arity' | 'namedArguments'>,
): E[] {
if (call.arity === undefined) return candidates;
const positional = call.arity - call.namedArguments.length;
return candidates.filter((c) => {
if (c.def.fromFallback) return true; // parameters unknown
const params = c.def.parameters;
if (positional < 0 || positional > params.length) return false;
const named = new Set(call.namedArguments);
if (named.size !== call.namedArguments.length) return false; // duplicate name
const supplied = new Set(params.slice(0, positional).map((p) => p.name));
for (const name of named) {
const param = params.find((p) => p.name === name);
if (!param || supplied.has(name)) return false;
supplied.add(name);
}
return params.every((p) => p.hasDefault || supplied.has(p.name));
});
}
/**
* A spec (or forward) declaration and its body describe one subprogram; prefer
* the body. Pairing is per package instance, so a body in one copy of a
* package never hides the declaration of another copy. Declaration-only
* entries remain when their own instance has no body among the candidates.
*/
function preferImplementations<E extends PlSqlIndexEntry>(defs: E[]): E[] {
const bodies = new Set(defs.filter((d) => !d.def.declarationOnly).map(entityKey));
return defs.filter((d) => !d.def.declarationOnly || !bodies.has(entityKey(d)));
}
/**
* Pairs every package spec/body definition with one package instance: a spec
* is its own instance; a body joins the nearest spec of the same name whose
* schema is compatible (equal, or unwritten on either side), or is its own
* instance when no such spec is indexed. Copies of a package in unrelated
* folders, and same-named packages of different schemas, stay separate.
*/
export class PlSqlPackageInstances {
private readonly instance = new Map<string, string>();
private readonly home = new Map<string, string>();
constructor(
packages: ReadonlyArray<{
readonly name: string;
readonly schema?: string;
readonly filePath: string;
readonly isSpec: boolean;
}>,
) {
const specs = packages.filter((p) => p.isSpec);
for (const p of [...specs, ...packages.filter((q) => !q.isSpec)]) {
const key = entryKey(p.name, p.schema, p.filePath);
if (this.instance.has(key)) continue;
const spec = p.isSpec
? p
: pickSpec(
specs.filter(
(s) =>
s.name === p.name &&
(s.schema === undefined || p.schema === undefined || s.schema === p.schema),
),
p.filePath,
p.schema,
);
const owner = spec ?? p;
const id = entryKey(owner.name, owner.schema, owner.filePath);
this.instance.set(key, id);
this.home.set(id, owner.filePath);
}
}
/** Opaque instance id of package `name` (written schema `schema`) defined in `filePath`. */
instanceOf(name: string, schema: string | undefined, filePath: string): string {
return this.instance.get(entryKey(name, schema, filePath)) ?? entryKey(name, schema, filePath);
}
/** File that anchors an instance (its spec when indexed). */
homeOf(instanceId: string): string | undefined {
return this.home.get(instanceId);
}
}
function entryKey(name: string, schema: string | undefined, filePath: string): string {
return `${name}\0${schema ?? ''}\0${filePath}`;
}
/** Nearest compatible spec; an exact schema match wins a distance tie, then path order. */
function pickSpec<S extends { filePath: string; schema?: string }>(
specs: readonly S[],
fromFile: string,
schema: string | undefined,
): S | undefined {
const nearest = nearestByPath(specs, fromFile);
const exact = nearest.filter((s) => s.schema === schema);
return [...(exact.length > 0 ? exact : nearest)].sort((a, b) =>
a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0,
)[0];
}

View file

@ -0,0 +1,53 @@
/**
* SQL*Plus script inclusion (`@@file`, `@file`, `START file`) → repository path.
*
* SQL*Plus resolves `@@` against the directory of the calling script and
* `@` / `START` against the working directory, appending `.sql` when the name
* has no extension. The working directory is unknown statically, so `@` /
* `START` try the repository root first and then the calling script's
* directory (the two directories install scripts are run from); `@@` uses only
* the calling script's directory. Exact paths only — there is no suffix
* search: a script that is not at one of those paths lives outside the
* repository (a vendor install, `?/rdbms/admin/...`, a UNC share) and must not
* bind to a same-named file.
*
* `targetRaw` may carry a leading `@@` (how scope captures encode a
* script-relative include); `relativeToScript` overrides it when given.
*/
import path from 'node:path';
function normalise(p: string): string {
return path.posix.normalize(p.replace(/\\/g, '/')).replace(/^\.\//, '');
}
export function resolvePlSqlScriptInclude(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
relativeToScript?: boolean,
): string | null {
let target = targetRaw.trim();
const scriptRelative = relativeToScript ?? target.startsWith('@@');
target = target.replace(/^@@?/, '').replace(/^["']|["']$/g, '');
// `?` is ORACLE_HOME; absolute, drive-letter and UNC paths are machine-specific.
if (
target === '' ||
target.startsWith('?') ||
/^[\\/]/.test(target) ||
/^[A-Za-z]:/.test(target)
) {
return null;
}
const spellings = path.posix.extname(target) === '' ? [`${target}.sql`, target] : [target];
const dir = path.posix.dirname(fromFile.replace(/\\/g, '/'));
const bases = scriptRelative ? [dir] : ['', dir];
for (const base of bases) {
for (const spelling of spellings) {
const candidate = normalise(base && base !== '.' ? `${base}/${spelling}` : spelling);
if (candidate.startsWith('../')) continue;
if (allFilePaths.has(candidate)) return candidate;
}
}
return null;
}

View file

@ -24,6 +24,7 @@ import { javascriptScopeResolver } from '../../languages/javascript/scope-resolv
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
import { rubyScopeResolver } from '../../languages/ruby/scope-resolver.js';
import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js';
import { plsqlScopeResolver } from '../../languages/plsql/scope-resolver.js';
import { swiftScopeResolver } from '../../languages/swift/scope-resolver.js';
import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
@ -50,6 +51,7 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Kotlin, kotlinScopeResolver],
[SupportedLanguages.Ruby, rubyScopeResolver],
[SupportedLanguages.Cobol, cobolScopeResolver],
[SupportedLanguages.PlSql, plsqlScopeResolver],
[SupportedLanguages.Swift, swiftScopeResolver],
[SupportedLanguages.Dart, dartScopeResolver],
[SupportedLanguages.Vue, vueScopeResolver],

View file

@ -3087,5 +3087,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
[SupportedLanguages.PlSql]: '', // Standalone ANTLR4 processor — no tree-sitter queries
[SupportedLanguages.Zig]: ZIG_QUERIES,
};

View file

@ -52,6 +52,7 @@ export const PARSE_CACHE_PACKAGE_INPUTS = [
'smol-toml',
'glob',
'ignore',
'antlr4ng',
] as const;
// These extraction-only helpers live beside their provider. flow-source stays

View file

@ -0,0 +1,13 @@
-- Installs the loan settlement schema objects in dependency order.
SET DEFINE OFF
WHENEVER SQLERROR EXIT FAILURE
PROMPT Installing packages...
@@packages/pkg_log.pks
@@packages/pkg_log.pkb
@@packages/pkg_loan_settle.pks
@@packages/pkg_loan_settle.pkb
@@procedures/prc_archive.prc
@@triggers/trg_repay_plan_audit.sql
-- Shipped by the vendor, not part of this repository.
@@vendor_patch.sql
EXIT

View file

@ -0,0 +1,68 @@
CREATE OR REPLACE PACKAGE BODY loan.pkg_loan_settle AS
g_batch_no VARCHAR2(32);
-- Private: not declared in the spec.
FUNCTION calc_penalty(p_loan_id IN NUMBER, p_days IN NUMBER) RETURN NUMBER IS
l_rate NUMBER;
BEGIN
SELECT p.penalty_rate INTO l_rate
FROM t_loan_product p
WHERE p.product_id = (SELECT c.product_id FROM t_loan_contract c WHERE c.loan_id = p_loan_id);
RETURN ROUND(l_rate * p_days, 2);
END calc_penalty;
PROCEDURE write_journal(p_loan_id NUMBER, p_amt NUMBER, p_type VARCHAR2) IS
BEGIN
INSERT INTO t_acct_journal (journal_id, loan_id, amount, journal_type, batch_no)
VALUES (seq_journal.NEXTVAL, p_loan_id, p_amt, p_type, g_batch_no);
END write_journal;
PROCEDURE settle_one(p_loan_id IN NUMBER) IS
l_overdue_days NUMBER;
l_penalty NUMBER;
PROCEDURE mark_settled IS
BEGIN
UPDATE t_repay_plan SET status = 'SETTLED', settle_time = SYSTIMESTAMP
WHERE loan_id = p_loan_id AND status = 'DUE';
END mark_settled;
BEGIN
SELECT TRUNC(SYSDATE) - due_date INTO l_overdue_days
FROM t_repay_plan WHERE loan_id = p_loan_id AND status = 'DUE' AND ROWNUM = 1;
IF l_overdue_days > 0 THEN
l_penalty := calc_penalty(p_loan_id, l_overdue_days);
write_journal(p_loan_id, l_penalty, 'PENALTY');
pkg_acct.freeze@core_link(p_loan_id);
END IF;
mark_settled;
MERGE INTO t_loan_balance b
USING (SELECT loan_id, SUM(amount) amt FROM t_acct_journal WHERE loan_id = p_loan_id GROUP BY loan_id) j
ON (b.loan_id = j.loan_id)
WHEN MATCHED THEN UPDATE SET b.balance = b.balance - j.amt
WHEN NOT MATCHED THEN INSERT (loan_id, balance) VALUES (j.loan_id, -j.amt);
EXCEPTION
WHEN NO_DATA_FOUND THEN
pkg_log.warn('no due plan for ' || p_loan_id);
END settle_one;
PROCEDURE run_batch(p_biz_date IN DATE DEFAULT TRUNC(SYSDATE)) IS
CURSOR c_due IS
WITH due AS (SELECT loan_id FROM t_repay_plan WHERE due_date <= p_biz_date AND status = 'DUE')
SELECT loan_id FROM due;
l_today DATE;
BEGIN
SELECT SYSDATE INTO l_today FROM dual;
g_batch_no := TO_CHAR(p_biz_date, 'YYYYMMDD') || '_' || seq_batch.NEXTVAL;
FOR r IN c_due LOOP
settle_one(r.loan_id);
END LOOP;
EXECUTE IMMEDIATE 'TRUNCATE TABLE t_settle_stage';
pkg_log.log_msg('batch ' || g_batch_no || ' done');
DBMS_OUTPUT.PUT_LINE('done');
END run_batch;
END pkg_loan_settle;
/

View file

@ -0,0 +1,5 @@
CREATE OR REPLACE PACKAGE loan.pkg_loan_settle AS
PROCEDURE settle_one(p_loan_id IN NUMBER);
PROCEDURE run_batch(p_biz_date IN DATE DEFAULT TRUNC(SYSDATE));
END pkg_loan_settle;
/

View file

@ -0,0 +1,21 @@
CREATE OR REPLACE PACKAGE BODY pkg_log AS
PROCEDURE log_msg(p_msg IN VARCHAR2) IS
BEGIN
log_msg(p_msg, 1);
END log_msg;
PROCEDURE log_msg(p_msg IN VARCHAR2, p_level IN NUMBER) IS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
INSERT INTO t_app_log (log_time, log_level, message) VALUES (SYSTIMESTAMP, p_level, p_msg);
COMMIT;
END log_msg;
PROCEDURE warn(p_msg IN VARCHAR2) IS
BEGIN
log_msg(p_msg => p_msg, p_level => 2);
END warn;
END pkg_log;
/

View file

@ -0,0 +1,6 @@
CREATE OR REPLACE PACKAGE pkg_log AS
PROCEDURE log_msg(p_msg IN VARCHAR2);
PROCEDURE log_msg(p_msg IN VARCHAR2, p_level IN NUMBER);
PROCEDURE warn(p_msg IN VARCHAR2);
END pkg_log;
/

View file

@ -0,0 +1,5 @@
CREATE OR REPLACE FUNCTION mark_items_dirty(p_id integer) RETURNS void AS $$
BEGIN
UPDATE items SET dirty = true WHERE id = p_id;
END;
$$ LANGUAGE plpgsql;

View file

@ -0,0 +1,8 @@
CREATE OR REPLACE PROCEDURE prc_archive(p_before IN DATE) AS
BEGIN
loan.pkg_loan_settle.run_batch(p_before);
INSERT INTO t_repay_plan_hist SELECT * FROM t_repay_plan WHERE settle_time < p_before;
DELETE FROM t_repay_plan WHERE settle_time < p_before;
pkg_log.log_msg('archived', 1);
END prc_archive;
/

View file

@ -0,0 +1,9 @@
CREATE OR REPLACE TRIGGER trg_repay_plan_audit
AFTER UPDATE OF status ON t_repay_plan
FOR EACH ROW
BEGIN
INSERT INTO t_audit_log (tbl, pk, old_val, new_val)
VALUES ('T_REPAY_PLAN', :NEW.loan_id, :OLD.status, :NEW.status);
pkg_log.warn('status changed');
END;
/

View file

@ -0,0 +1,16 @@
CREATE OR REPLACE PACKAGE BODY pkg_log AS
PROCEDURE info(p_msg IN VARCHAR2) IS
BEGIN
INSERT INTO t_app_log (log_level, message) VALUES ('info', p_msg);
END info;
-- warn writes a warning row and echoes it
PROCEDURE notify(p_msg IN VARCHAR2) IS
BEGIN
INSERT INTO t_app_log (log_level, message) VALUES ('warn', p_msg);
info('warn: ' || p_msg);
END notify;
END pkg_log;
/

View file

@ -0,0 +1,5 @@
CREATE OR REPLACE PACKAGE pkg_log AS
PROCEDURE info(p_msg IN VARCHAR2);
PROCEDURE notify(p_msg IN VARCHAR2);
END pkg_log;
/

View file

@ -0,0 +1,8 @@
CREATE OR REPLACE PROCEDURE prc_settle(p_loan_id IN NUMBER) AS
BEGIN
UPDATE t_repay_plan SET status = 'SETTLED' WHERE loan_id = p_loan_id;
IF SQL%ROWCOUNT = 0 THEN
pkg_log.notify('nothing to settle for ' || p_loan_id);
END IF;
END prc_settle;
/

View file

@ -0,0 +1,16 @@
CREATE OR REPLACE PACKAGE BODY pkg_log AS
PROCEDURE info(p_msg IN VARCHAR2) IS
BEGIN
INSERT INTO t_app_log (log_level, message) VALUES ('info', p_msg);
END info;
-- warn writes a warning row and echoes it
PROCEDURE warn(p_msg IN VARCHAR2) IS
BEGIN
INSERT INTO t_app_log (log_level, message) VALUES ('warn', p_msg);
info('warn: ' || p_msg);
END warn;
END pkg_log;
/

View file

@ -0,0 +1,5 @@
CREATE OR REPLACE PACKAGE pkg_log AS
PROCEDURE info(p_msg IN VARCHAR2);
PROCEDURE warn(p_msg IN VARCHAR2);
END pkg_log;
/

View file

@ -0,0 +1,8 @@
CREATE OR REPLACE PROCEDURE prc_settle(p_loan_id IN NUMBER) AS
BEGIN
UPDATE t_repay_plan SET status = 'SETTLED' WHERE loan_id = p_loan_id;
IF SQL%ROWCOUNT = 0 THEN
pkg_log.warn('nothing to settle for ' || p_loan_id);
END IF;
END prc_settle;
/

View file

@ -0,0 +1,37 @@
{
"language": "plsql",
"description": "Rename a package procedure: its spec declaration, body, END label and a cross-file package-qualified call change together; a same-spelled string literal and comment do not.",
"categories": [
"call-graph",
"cross-file",
"package-scope"
],
"target": {
"name": "WARN",
"kind": "Function",
"file": "pkg_log.pkb"
},
"newName": "notify",
"expectedTargetName": "NOTIFY",
"expect": {
"status": "success",
"edits": 4
},
"edges": [
{
"type": "CALLS",
"from": {
"name": "PRC_SETTLE",
"kind": "Function",
"file": "prc_settle.prc",
"startLine": 0
},
"to": {
"name": "WARN",
"kind": "Function",
"file": "pkg_log.pkb",
"startLine": 8
}
}
]
}

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PROCEDURE caller IS
BEGIN
core.info;
-- a comment naming app and a literal stay as they are
DBMS_OUTPUT.PUT_LINE('app');
END caller;
/

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PACKAGE BODY core AS
PROCEDURE info IS
BEGIN
NULL;
END info;
END core;
/

View file

@ -0,0 +1,4 @@
CREATE OR REPLACE PACKAGE core AS
PROCEDURE info;
END core;
/

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PROCEDURE caller IS
BEGIN
app.info;
-- a comment naming app and a literal stay as they are
DBMS_OUTPUT.PUT_LINE('app');
END caller;
/

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PACKAGE BODY app AS
PROCEDURE info IS
BEGIN
NULL;
END info;
END app;
/

View file

@ -0,0 +1,4 @@
CREATE OR REPLACE PACKAGE app AS
PROCEDURE info;
END app;
/

View file

@ -0,0 +1,16 @@
{
"language": "plsql",
"description": "Rename a package: spec and body headers, both END labels and a cross-file package-qualified call change together; a same-spelled comment and string literal do not.",
"categories": ["package-scope", "cross-file"],
"target": {
"name": "APP",
"kind": "Module",
"file": "pkg_app.pks"
},
"newName": "core",
"expectedTargetName": "CORE",
"expect": {
"status": "success",
"edits": 5
}
}

View file

@ -0,0 +1,15 @@
CREATE OR REPLACE PACKAGE BODY pkg_fee AS
FUNCTION calc_fee(p_amt IN NUMBER) RETURN NUMBER IS
BEGIN
RETURN ROUND(p_amt * 0.003, 2);
END calc_fee;
PROCEDURE reprice(p_batch IN VARCHAR2) IS
BEGIN
-- The fee function is also invoked from a generated statement.
EXECUTE IMMEDIATE 'UPDATE t_txn SET fee = pkg_fee.calc_fee(amount) WHERE batch_no = :1' USING p_batch;
END reprice;
END pkg_fee;
/

View file

@ -0,0 +1,15 @@
CREATE OR REPLACE PACKAGE BODY pkg_fee AS
FUNCTION calc_fee(p_amt IN NUMBER) RETURN NUMBER IS
BEGIN
RETURN ROUND(p_amt * 0.003, 2);
END calc_fee;
PROCEDURE reprice(p_batch IN VARCHAR2) IS
BEGIN
-- The fee function is also invoked from a generated statement.
EXECUTE IMMEDIATE 'UPDATE t_txn SET fee = pkg_fee.calc_fee(amount) WHERE batch_no = :1' USING p_batch;
END reprice;
END pkg_fee;
/

View file

@ -0,0 +1,16 @@
{
"language": "plsql",
"description": "Refuse to rename a function that dynamic SQL text also names: the string cannot be rewritten safely, so every source byte must stay unchanged.",
"categories": ["dynamic-sql", "negative", "unchanged-on-error"],
"target": {
"name": "CALC_FEE",
"kind": "Function",
"file": "pkg_fee.pkb"
},
"newName": "calc_service_fee",
"expect": {
"status": "error",
"code": "unsupported_occurrence",
"planningStatus": "unsupported"
}
}

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PROCEDURE caller IS
BEGIN
app.info;
-- `n` is no member of package APP: here `app` can only be a schema.
app.n;
END caller;
/

View file

@ -0,0 +1,5 @@
CREATE OR REPLACE PROCEDURE n IS
BEGIN
NULL;
END n;
/

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PACKAGE BODY app AS
PROCEDURE info IS
BEGIN
NULL;
END info;
END app;
/

View file

@ -0,0 +1,4 @@
CREATE OR REPLACE PACKAGE app AS
PROCEDURE info;
END app;
/

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PROCEDURE caller IS
BEGIN
app.info;
-- `n` is no member of package APP: here `app` can only be a schema.
app.n;
END caller;
/

View file

@ -0,0 +1,5 @@
CREATE OR REPLACE PROCEDURE n IS
BEGIN
NULL;
END n;
/

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PACKAGE BODY app AS
PROCEDURE info IS
BEGIN
NULL;
END info;
END app;
/

View file

@ -0,0 +1,4 @@
CREATE OR REPLACE PACKAGE app AS
PROCEDURE info;
END app;
/

View file

@ -0,0 +1,16 @@
{
"language": "plsql",
"description": "Refuse to rename package APP when a call `app.n` resolves to a standalone procedure: there `app` is a schema qualifier, not the package, so it must not be rewritten and every source byte must stay unchanged.",
"categories": ["package-scope", "negative", "unchanged-on-error"],
"target": {
"name": "APP",
"kind": "Module",
"file": "pkg_app.pks"
},
"newName": "core",
"expect": {
"status": "error",
"code": "ambiguous_reference",
"planningStatus": "blocked"
}
}

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PROCEDURE prc_deploy_marker IS
BEGIN
NULL;
END prc_deploy_marker;
/
-- Run the daily job once right after deployment.
EXEC pkg_job.run_daily;

View file

@ -0,0 +1,9 @@
CREATE OR REPLACE PACKAGE BODY pkg_job AS
PROCEDURE run_daily IS
BEGIN
DELETE FROM t_job_log WHERE log_date < TRUNC(SYSDATE) - 30;
END run_daily;
END pkg_job;
/

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PROCEDURE prc_deploy_marker IS
BEGIN
NULL;
END prc_deploy_marker;
/
-- Run the daily job once right after deployment.
EXEC pkg_job.run_daily;

View file

@ -0,0 +1,9 @@
CREATE OR REPLACE PACKAGE BODY pkg_job AS
PROCEDURE run_daily IS
BEGIN
DELETE FROM t_job_log WHERE log_date < TRUNC(SYSDATE) - 30;
END run_daily;
END pkg_job;
/

View file

@ -0,0 +1,16 @@
{
"language": "plsql",
"description": "Refuse to rename a procedure that a SQL*Plus EXEC command invokes: client commands are not part of the parsed PL/SQL, so the call could not be rewritten and every source byte must stay unchanged.",
"categories": ["sqlplus", "negative", "unchanged-on-error"],
"target": {
"name": "RUN_DAILY",
"kind": "Function",
"file": "pkg_job.pkb"
},
"newName": "run_nightly",
"expect": {
"status": "error",
"code": "unsupported_occurrence",
"planningStatus": "unsupported"
}
}

View file

@ -0,0 +1,3 @@
import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js';
defineRenameFixtureTests('plsql');

View file

@ -41,6 +41,9 @@ const CALLABLE_FLOW_PROVIDER_COVERAGE = {
// Objective-C message sends are resolved by its ScopeResolver. The provider
// does not emit callable-value-flow captures in this MVP.
[SupportedLanguages.ObjectiveC]: 'not-applicable',
// PL/SQL has no first-class callable values; calls are structural edges
// written by the PL/SQL processor.
[SupportedLanguages.PlSql]: 'not-applicable',
} as const satisfies Record<SupportedLanguages, CallableFlowCoverage>;
const PROVIDER_FLOW_CASES = [

View file

@ -0,0 +1,205 @@
/**
* Oracle PL/SQL: strict integration test over the plsql-app fixture.
*
* Every PL/SQL node and edge is asserted with exact sorted lists, including
* the negatives: built-ins, database-link calls, CTE names, DUAL, scripts
* outside the repository and a PostgreSQL `.sql` decoy produce nothing.
*
* db/install.sql SQL*Plus install script (@@ includes)
* db/packages/pkg_log.{pks,pkb} overloaded LOG_MSG, named arguments
* db/packages/pkg_loan_settle.{pks,pkb} schema-qualified package, private
* and nested subprograms, MERGE, CTE
* db/procedures/prc_archive.prc standalone procedure, INSERT…SELECT
* db/triggers/trg_repay_plan_audit.sql trigger in a .sql file
* db/postgres/fn_pg.sql PL/pgSQL decoy — must not be claimed
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
const plsqlNodes = (result: PipelineResult, label: string) => {
const out: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === label && n.properties.language === 'plsql') out.push(n.properties.name);
});
return out.sort();
};
const plsqlEdges = (result: PipelineResult, type: string) =>
getRelationships(result, type)
.filter((e) => e.rel.reason.startsWith('plsql'))
.map((e) => `${e.source} → ${e.target} [${e.rel.reason}]`)
.sort();
describe('Oracle PL/SQL extraction', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'plsql-app'), () => {}, {
skipGraphPhases: true,
});
}, 120000);
describe('nodes', () => {
it('merges each package spec and body into one Module anchored at the spec', () => {
expect(plsqlNodes(result, 'Module')).toEqual(['PKG_LOAN_SETTLE', 'PKG_LOG']);
let anchor: string | undefined;
result.graph.forEachNode((n) => {
if (n.label === 'Module' && n.properties.name === 'PKG_LOAN_SETTLE')
anchor = n.properties.filePath;
});
expect(anchor).toBe('db/packages/pkg_loan_settle.pks');
});
it('creates one Function per subprogram body, overloads and nested subprograms included', () => {
expect(plsqlNodes(result, 'Function')).toEqual([
'CALC_PENALTY',
'LOG_MSG',
'LOG_MSG',
'MARK_SETTLED',
'PRC_ARCHIVE',
'RUN_BATCH',
'SETTLE_ONE',
'TRG_REPAY_PLAN_AUDIT',
'WARN',
'WRITE_JOURNAL',
]);
});
it('exports package members exactly when the spec declares them', () => {
const exported: Record<string, boolean | undefined> = {};
result.graph.forEachNode((n) => {
if (n.label === 'Function' && n.properties.language === 'plsql')
exported[n.properties.name] = n.properties.isExported;
});
expect(exported).toMatchObject({
SETTLE_ONE: true,
RUN_BATCH: true,
CALC_PENALTY: false,
WRITE_JOURNAL: false,
MARK_SETTLED: false,
PRC_ARCHIVE: true,
});
});
it('creates one Record per referenced table, never for CTEs or DUAL', () => {
expect(plsqlNodes(result, 'Record')).toEqual([
'T_ACCT_JOURNAL',
'T_APP_LOG',
'T_AUDIT_LOG',
'T_LOAN_BALANCE',
'T_LOAN_CONTRACT',
'T_LOAN_PRODUCT',
'T_REPAY_PLAN',
'T_REPAY_PLAN_HIST',
]);
});
it('does not claim the PL/pgSQL .sql file', () => {
let claimed = false;
result.graph.forEachNode((n) => {
if (n.properties.language === 'plsql' && n.properties.filePath === 'db/postgres/fn_pg.sql')
claimed = true;
});
expect(claimed).toBe(false);
});
});
describe('edges', () => {
it('CONTAINS: files → packages, packages → members, procedures → nested subprograms', () => {
expect(plsqlEdges(result, 'CONTAINS')).toEqual([
'PKG_LOAN_SETTLE → CALC_PENALTY [plsql-subprogram]',
'PKG_LOAN_SETTLE → RUN_BATCH [plsql-subprogram]',
'PKG_LOAN_SETTLE → SETTLE_ONE [plsql-subprogram]',
'PKG_LOAN_SETTLE → WRITE_JOURNAL [plsql-subprogram]',
'PKG_LOG → LOG_MSG [plsql-subprogram]',
'PKG_LOG → LOG_MSG [plsql-subprogram]',
'PKG_LOG → WARN [plsql-subprogram]',
'SETTLE_ONE → MARK_SETTLED [plsql-subprogram]',
'pkg_loan_settle.pkb → PKG_LOAN_SETTLE [plsql-package]',
'pkg_loan_settle.pks → PKG_LOAN_SETTLE [plsql-package]',
'pkg_log.pkb → PKG_LOG [plsql-package]',
'pkg_log.pks → PKG_LOG [plsql-package]',
'prc_archive.prc → PRC_ARCHIVE [plsql-subprogram]',
'trg_repay_plan_audit.sql → TRG_REPAY_PLAN_AUDIT [plsql-trigger]',
]);
});
it('CALLS: local, nested, package-qualified and schema-qualified calls; no built-ins or db links', () => {
expect(plsqlEdges(result, 'CALLS')).toEqual([
'LOG_MSG → LOG_MSG [plsql-call]',
'PRC_ARCHIVE → LOG_MSG [plsql-call]',
'PRC_ARCHIVE → RUN_BATCH [plsql-call]',
'RUN_BATCH → LOG_MSG [plsql-call]',
'RUN_BATCH → SETTLE_ONE [plsql-call]',
'SETTLE_ONE → CALC_PENALTY [plsql-call]',
'SETTLE_ONE → MARK_SETTLED [plsql-call]',
'SETTLE_ONE → WARN [plsql-call]',
'SETTLE_ONE → WRITE_JOURNAL [plsql-call]',
'TRG_REPAY_PLAN_AUDIT → WARN [plsql-call]',
'WARN → LOG_MSG [plsql-call]',
]);
});
it('CALLS: overloads are selected by arity and named arguments', () => {
const overloadLine = new Map<string, number>();
result.graph.forEachNode((n) => {
if (n.label === 'Function' && n.properties.name === 'LOG_MSG') {
overloadLine.set(n.id, n.properties.startLine! + 1);
}
});
const calls = getRelationships(result, 'CALLS')
.filter((e) => e.target === 'LOG_MSG')
.map((e) => `${e.source}@${e.sourceLabel} → LOG_MSG@L${overloadLine.get(e.rel.targetId)}`)
.sort();
// L3: log_msg(p_msg) L8: log_msg(p_msg, p_level)
expect(calls).toEqual([
'LOG_MSG@Function → LOG_MSG@L8', // log_msg(p_msg, 1)
'PRC_ARCHIVE@Function → LOG_MSG@L8', // log_msg('archived', 1)
'RUN_BATCH@Function → LOG_MSG@L3', // pkg_log.log_msg('…')
'WARN@Function → LOG_MSG@L8', // log_msg(p_msg => …, p_level => 2)
]);
});
it('CALLS/ACCESSES: sites belong to the overload whose body contains them', () => {
const line = (id: string) => result.graph.getNode(id)!.properties.startLine! + 1;
const fromLogMsg = [...result.graph.iterRelationships()]
.filter((r) => result.graph.getNode(r.sourceId)?.properties.name === 'LOG_MSG')
.filter((r) => r.type === 'CALLS' || r.type === 'ACCESSES')
.map((r) => `${r.type} LOG_MSG@L${line(r.sourceId)}`)
.sort();
// L3 log_msg(p_msg) calls the 2-arg overload; L8's body does the INSERT.
expect(fromLogMsg).toEqual(['ACCESSES LOG_MSG@L8', 'CALLS LOG_MSG@L3']);
});
it('ACCESSES: read/write per statement, MERGE target vs source, trigger target', () => {
expect(plsqlEdges(result, 'ACCESSES')).toEqual([
'CALC_PENALTY → T_LOAN_CONTRACT [plsql-select]',
'CALC_PENALTY → T_LOAN_PRODUCT [plsql-select]',
'LOG_MSG → T_APP_LOG [plsql-insert]',
'MARK_SETTLED → T_REPAY_PLAN [plsql-update]',
'PRC_ARCHIVE → T_REPAY_PLAN [plsql-delete]',
'PRC_ARCHIVE → T_REPAY_PLAN [plsql-select]',
'PRC_ARCHIVE → T_REPAY_PLAN_HIST [plsql-insert]',
'RUN_BATCH → T_REPAY_PLAN [plsql-select]',
'SETTLE_ONE → T_ACCT_JOURNAL [plsql-select]',
'SETTLE_ONE → T_LOAN_BALANCE [plsql-merge]',
'SETTLE_ONE → T_REPAY_PLAN [plsql-select]',
'TRG_REPAY_PLAN_AUDIT → T_AUDIT_LOG [plsql-insert]',
'TRG_REPAY_PLAN_AUDIT → T_REPAY_PLAN [plsql-trigger]',
'WRITE_JOURNAL → T_ACCT_JOURNAL [plsql-insert]',
]);
});
it('IMPORTS: SQL*Plus @@ includes that exist in the repository', () => {
expect(plsqlEdges(result, 'IMPORTS')).toEqual([
'install.sql → pkg_loan_settle.pkb [plsql-script-include]',
'install.sql → pkg_loan_settle.pks [plsql-script-include]',
'install.sql → pkg_log.pkb [plsql-script-include]',
'install.sql → pkg_log.pks [plsql-script-include]',
'install.sql → prc_archive.prc [plsql-script-include]',
'install.sql → trg_repay_plan_audit.sql [plsql-script-include]',
]);
});
});
});

View file

@ -69,6 +69,7 @@ const FULL_ORDER = [
'springConfig',
'markdown',
'cobol',
'plsql',
'parse',
'routes',
'tools',

View file

@ -0,0 +1,370 @@
import { describe, it, expect } from 'vitest';
import { extractPlSqlFacts } from '../../src/core/ingestion/plsql/extract.js';
import { isPlSqlSource, looksLikeOraclePlSql } from '../../src/core/ingestion/plsql/file-types.js';
import { resolvePlSqlScriptInclude } from '../../src/core/ingestion/plsql/script-include.js';
const BODY = `CREATE OR REPLACE PACKAGE BODY app.pkg_a AS
FUNCTION f(p IN NUMBER, q IN NUMBER DEFAULT 0) RETURN NUMBER IS
BEGIN
RETURN p + q;
END f;
PROCEDURE run IS
l NUMBER;
PROCEDURE inner_step IS BEGIN NULL; END inner_step;
BEGIN
l := f(1);
inner_step;
pkg_b.go(x => 1);
other_schema.pkg_c.stop@remote_db;
WITH w AS (SELECT id FROM t_src) SELECT COUNT(*) INTO l FROM w, dual;
INSERT INTO t_dst SELECT * FROM t_src;
UPDATE "MixedCase" SET a = 1;
DELETE FROM app.t_old;
EXECUTE IMMEDIATE 'DROP TABLE t_tmp';
END run;
END pkg_a;
/
`;
describe('extractPlSqlFacts', () => {
const facts = extractPlSqlFacts(BODY);
it('parses the package without errors in one segment', () => {
expect(facts.segments).toBe(1);
expect(facts.segmentsWithErrors).toBe(0);
});
it('extracts definitions with owner paths, parameters, positions and END labels', () => {
expect(facts.definitions.map((d) => `${d.kind} ${[...d.ownerPath, d.name].join('.')}`)).toEqual(
[
'package PKG_A',
'function PKG_A.F',
'procedure PKG_A.RUN',
'procedure PKG_A.RUN.INNER_STEP',
],
);
const pkg = facts.definitions[0]!;
expect(pkg.schema).toBe('APP');
expect(pkg.endLabels.map((l) => `${l.name}@${l.line}:${l.column}`)).toEqual(['PKG_A@21:4']);
const f = facts.definitions[1]!;
expect(f.parameters).toEqual([
{ name: 'P', hasDefault: false, type: 'NUMBER' },
{ name: 'Q', hasDefault: true, type: 'NUMBER' },
]);
expect({ line: f.nameLine, column: f.nameColumn, length: f.nameLength }).toEqual({
line: 2,
column: 11,
length: 1,
});
expect(f.endLabels.map((l) => l.line)).toEqual([5]);
});
it('extracts call sites with name tokens, arity, named arguments and db links', () => {
const describe = (c: (typeof facts.calls)[number]) =>
[
`${c.nameParts.join('.')}/${c.arity}`,
c.namedArguments.length > 0 ? `named=${c.namedArguments.join(',')}` : '',
c.dbLink ? `@${c.dbLink}` : '',
c.form,
]
.filter(Boolean)
.join(' ');
// COUNT(*) is an aggregate rule, not a call; built-ins are filtered later by resolution.
expect(facts.calls.map(describe)).toEqual([
'F/1 expression',
'INNER_STEP/0 statement',
'PKG_B.GO/1 named=X statement',
'OTHER_SCHEMA.PKG_C.STOP/0 @REMOTE_DB statement',
]);
const go = facts.calls.find((c) => c.nameParts.join('.') === 'PKG_B.GO')!;
expect(go.nameTokens.map((t) => `${t.name}@${t.line}:${t.column}`)).toEqual([
'PKG_B@13:4',
'GO@13:10',
]);
});
it('classifies table accesses and filters CTE names and DUAL', () => {
expect(
facts.accesses.map((a) => `${a.kind} ${a.schema ? `${a.schema}.` : ''}${a.table}`),
).toEqual([
'select T_SRC',
'insert T_DST',
'select T_SRC',
'update MixedCase',
'delete APP.T_OLD',
]);
});
it('records dynamic SQL with its literal text', () => {
expect(facts.dynamicSql.map((d) => d.literal)).toEqual(['DROP TABLE t_tmp']);
});
it('recovers definitions the error-recovered tree lost, marked as fallback', () => {
const broken = `CREATE OR REPLACE PACKAGE BODY pkg_x AS
PROCEDURE a IS BEGIN this is not plsql at all; END a;
PROCEDURE b IS BEGIN NULL; END b;
END pkg_x;
/
`;
const f = extractPlSqlFacts(broken);
expect(f.segmentsWithErrors).toBe(1);
const names = f.definitions.map(
(d) => `${[...d.ownerPath, d.name].join('.')}${d.fromFallback ? ' (fallback)' : ''}`,
);
expect(names).toContain('PKG_X.B');
expect(names.some((n) => n.startsWith('PKG_X.A'))).toBe(true);
});
it('counts lexer errors, so a segment with an unlexable character is degraded', () => {
const f = extractPlSqlFacts('CREATE PROCEDURE p IS BEGIN x := 1 ` 2; END;\n/\n');
expect(f.segmentsWithErrors).toBe(1);
});
it('places fallback subprograms under the routine whose END span contains them', () => {
const f = extractPlSqlFacts(`CREATE OR REPLACE PACKAGE BODY pkg_x AS
PROCEDURE outer_p IS
PROCEDURE inner_p IS BEGIN this is not plsql; END inner_p;
BEGIN
inner_p;
END outer_p;
PROCEDURE other_p IS BEGIN NULL; END other_p;
END pkg_x;
/
`);
expect(f.segmentsWithErrors).toBe(1);
const paths = f.definitions.map((d) => [...d.ownerPath, d.name].join('.'));
expect(paths).toContain('PKG_X.OUTER_P.INNER_P');
expect(paths).not.toContain('PKG_X.INNER_P');
});
it('does not split at a lone `/` inside a block comment or a string literal', () => {
const src = [
'CREATE OR REPLACE PROCEDURE p IS',
' /* usage:',
'/',
' */',
" l VARCHAR2(10) := 'a",
'/',
"b';",
" m VARCHAR2(10) := q'[x",
'/',
"]';",
'BEGIN NULL; END;',
'/',
].join('\n');
const f = extractPlSqlFacts(src);
expect(f.segments).toBe(1);
expect(f.segmentsWithErrors).toBe(0);
expect(f.definitions.map((d) => d.name)).toEqual(['P']);
});
it('abandons a segment at the deadline, including while lexing, and falls back', () => {
const body = Array.from(
{ length: 200 },
(_, i) => ` PROCEDURE p${i} IS BEGIN x := ${i}; END p${i};`,
).join('\n');
const f = extractPlSqlFacts(`CREATE OR REPLACE PACKAGE BODY big AS\n${body}\nEND big;\n/\n`, {
segmentDeadlineMs: 0,
});
expect(f.segmentsTimedOut).toBe(1);
expect(f.definitions.filter((d) => d.fromFallback).length).toBeGreaterThan(100);
});
it('reports a timeout when the budget runs out with fewer than 256 guard ticks', () => {
// Too small for the throttled checks to read the clock: only the
// unthrottled checks after each prediction and before accepting the tree
// can notice the deadline.
const f = extractPlSqlFacts('CREATE PROCEDURE p IS BEGIN NULL; END;\n/\n', {
segmentDeadlineMs: -1,
});
expect(f.segmentsTimedOut).toBe(1);
expect(f.definitions.map((d) => [d.name, d.fromFallback])).toEqual([['P', true]]);
});
it('keeps one segment per `/`-terminated unit so an error stays local', () => {
const f = extractPlSqlFacts(
'CREATE PROCEDURE ok1 IS BEGIN NULL; END;\n/\nCREATE PROCEDURE bad IS BEGIN ((( END;\n/\nCREATE PROCEDURE ok2 IS BEGIN NULL; END;\n/\n',
);
expect(f.segments).toBe(3);
expect(f.segmentsWithErrors).toBe(1);
expect(f.definitions.filter((d) => !d.fromFallback).map((d) => d.name)).toEqual(
expect.arrayContaining(['OK1', 'OK2']),
);
});
});
describe('file classification', () => {
it('always claims dedicated PL/SQL extensions', () => {
expect(isPlSqlSource('db/pkg.pkb', '')).toBe(true);
});
it('claims .sql only for Oracle PL/SQL units or SQL*Plus include scripts', () => {
expect(looksLikeOraclePlSql('CREATE OR REPLACE PROCEDURE p IS\nBEGIN NULL; END;\n/\n')).toBe(
true,
);
expect(looksLikeOraclePlSql('@@packages/pkg.pks\n@@packages/pkg.pkb\n')).toBe(true);
expect(
looksLikeOraclePlSql(
'CREATE FUNCTION f() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;',
),
).toBe(false);
expect(looksLikeOraclePlSql('CREATE PROCEDURE p AS\nBEGIN\n SET NOCOUNT ON;\nEND\nGO\n')).toBe(
false,
);
expect(looksLikeOraclePlSql('SELECT * FROM t;')).toBe(false);
// Triggers: Oracle-only bodies qualify; a PostgreSQL trigger does not.
expect(
looksLikeOraclePlSql(
'CREATE OR REPLACE TRIGGER t BEFORE UPDATE ON x FOR EACH ROW\nBEGIN\n :NEW.ts := SYSDATE;\nEND;',
),
).toBe(true);
expect(
looksLikeOraclePlSql(
'CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW EXECUTE FUNCTION f();',
),
).toBe(false);
// A plain PL/SQL trigger body with no Oracle-only marker still qualifies…
expect(
looksLikeOraclePlSql(
'CREATE OR REPLACE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nBEGIN\n NULL;\nEND;',
),
).toBe(true);
// …while MySQL/MariaDB trigger scripts do not.
expect(
looksLikeOraclePlSql(
'DELIMITER $$\nCREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nBEGIN\n SET NEW.a = 1;\nEND$$\nDELIMITER ;',
),
).toBe(false);
expect(
looksLikeOraclePlSql('CREATE TRIGGER t BEFORE INSERT ON `x` FOR EACH ROW SET NEW.a = 1;'),
).toBe(false);
// `AS` headers are shared with T-SQL: PL/SQL statement syntax decides.
expect(looksLikeOraclePlSql('CREATE PROCEDURE p AS BEGIN NULL; END;')).toBe(true);
expect(
looksLikeOraclePlSql(
'CREATE OR REPLACE FUNCTION f RETURN NUMBER AS\nBEGIN\n RETURN 1;\nEND;',
),
).toBe(true);
expect(looksLikeOraclePlSql('CREATE PROCEDURE p AS\nBEGIN\n SELECT 1;\nEND;')).toBe(false);
expect(
looksLikeOraclePlSql('CREATE PROCEDURE p AS\nBEGIN\n SET @x = NULL;\n SELECT @x;\nEND;'),
).toBe(false);
// T-SQL triggers name the table (`ON …`) before the timing keyword, so the
// Oracle trigger header never matches them.
expect(
looksLikeOraclePlSql(
'CREATE TRIGGER t ON dbo.x AFTER INSERT AS BEGIN UPDATE dbo.x SET a = 1; END;',
),
).toBe(false);
expect(
looksLikeOraclePlSql(
'CREATE OR ALTER TRIGGER t ON dbo.v INSTEAD OF INSERT AS BEGIN SELECT 1; END;',
),
).toBe(false);
// `IS` after the header is Oracle-only, no other marker needed.
expect(looksLikeOraclePlSql('CREATE PROCEDURE p IS BEGIN NULL; END;')).toBe(true);
// T-SQL without GO: `AS` header, `IS NULL` only inside the body.
expect(
looksLikeOraclePlSql('CREATE PROCEDURE p @x int AS\nBEGIN\n IF @x IS NULL RETURN;\nEND'),
).toBe(false);
// MySQL: no IS/AS before BEGIN, even with an Oracle-looking marker in a comment.
expect(
looksLikeOraclePlSql(
'CREATE PROCEDURE p()\nBEGIN\n -- nvl( is not MySQL\n SELECT 1;\nEND\n/\n',
),
).toBe(false);
// `CREATE OR REPLACE … AS` is Oracle even with a plain-SQL body; other
// dialects that write OR REPLACE declare LANGUAGE/RETURNS or skip IS/AS.
expect(
looksLikeOraclePlSql(
'CREATE OR REPLACE PROCEDURE p AS BEGIN INSERT INTO audit_log VALUES (1); END;',
),
).toBe(true);
expect(
looksLikeOraclePlSql(
"CREATE OR REPLACE PROCEDURE p() AS 'INSERT INTO t VALUES (1)' LANGUAGE sql;",
),
).toBe(false);
expect(looksLikeOraclePlSql("CREATE OR REPLACE FUNCTION f() RETURNS FLOAT AS '3.14';")).toBe(
false,
);
expect(looksLikeOraclePlSql('CREATE OR REPLACE FUNCTION ds.f(x INT64) AS (x + 1);')).toBe(
false,
);
expect(
looksLikeOraclePlSql(
'CREATE OR REPLACE FUNCTION ds.f(x INT64) AS (x + 1);\nSELECT a AS b FROM t;',
),
).toBe(false);
expect(
looksLikeOraclePlSql('CREATE OR REPLACE PROCEDURE p() BEGIN INSERT INTO t VALUES (1); END;'),
).toBe(false);
expect(
looksLikeOraclePlSql('CREATE OR ALTER PROCEDURE p AS BEGIN INSERT INTO t VALUES (1); END;'),
).toBe(false);
// Dialect evidence comes from code, never from comments or string literals.
expect(looksLikeOraclePlSql('CREATE PROCEDURE p AS BEGIN SELECT 1; -- :=\nEND;')).toBe(false);
expect(
looksLikeOraclePlSql("CREATE PROCEDURE p AS\nBEGIN\n SELECT 'END IF; x := 1';\nEND;"),
).toBe(false);
expect(
looksLikeOraclePlSql(
'/* CREATE PROCEDURE p IS */\nCREATE PROCEDURE p AS\nBEGIN\n SELECT 1;\nEND;',
),
).toBe(false);
expect(
looksLikeOraclePlSql("CREATE PROCEDURE p AS\nBEGIN\n SELECT q'[it's -- not]';\nEND;"),
).toBe(false);
// …while code next to a comment or literal still counts.
expect(
looksLikeOraclePlSql("CREATE PROCEDURE p AS -- note\nBEGIN\n x := 'a';\nEND;\n/\n"),
).toBe(true);
// A package header is Oracle-only even with `AS` and no other marker.
expect(looksLikeOraclePlSql('CREATE PACKAGE p AS PROCEDURE q(x NUMBER); END p;')).toBe(true);
expect(
looksLikeOraclePlSql(
'CREATE OR REPLACE PACKAGE BODY p AS\n PROCEDURE q IS BEGIN NULL; END;\nEND p;',
),
).toBe(true);
// Oracle header split across lines still counts.
expect(
looksLikeOraclePlSql('CREATE OR REPLACE\nPACKAGE BODY p\nAS\n x VARCHAR2(1);\nEND p;\n/\n'),
).toBe(true);
});
});
describe('resolvePlSqlScriptInclude', () => {
const files = new Set(['db/install.sql', 'db/pkg/a.pks', 'db/b.sql', 'vendor/ext.pks']);
it('resolves relative to the calling script, then the repository root, adding .sql', () => {
expect(resolvePlSqlScriptInclude('pkg/a.pks', 'db/install.sql', files)).toBe('db/pkg/a.pks');
expect(resolvePlSqlScriptInclude('b', 'db/install.sql', files)).toBe('db/b.sql');
expect(resolvePlSqlScriptInclude('vendor/ext.pks', 'db/install.sql', files)).toBe(
'vendor/ext.pks',
);
expect(resolvePlSqlScriptInclude('../vendor/ext.pks', 'db/install.sql', files)).toBe(
'vendor/ext.pks',
);
});
it('resolves @@ against the calling script and @ / START against the repository root first', () => {
const paths = new Set(['db/install.sql', 'db/foo.sql', 'foo.sql']);
expect(resolvePlSqlScriptInclude('foo', 'db/install.sql', paths, false)).toBe('foo.sql');
expect(resolvePlSqlScriptInclude('foo', 'db/install.sql', paths, true)).toBe('db/foo.sql');
expect(resolvePlSqlScriptInclude('@@foo', 'db/install.sql', paths)).toBe('db/foo.sql');
expect(
resolvePlSqlScriptInclude('ext.pks', 'db/install.sql', new Set(['ext.pks']), true),
).toBeNull();
});
it('never suffix-matches a script outside the repository', () => {
expect(resolvePlSqlScriptInclude('ext.pks', 'db/install.sql', files)).toBeNull();
expect(
resolvePlSqlScriptInclude('?/rdbms/admin/utlrp.sql', 'db/install.sql', files),
).toBeNull();
expect(resolvePlSqlScriptInclude('/opt/x.sql', 'db/install.sql', files)).toBeNull();
expect(
resolvePlSqlScriptInclude('\\\\server\\share\\x.sql', 'db/install.sql', files),
).toBeNull();
expect(resolvePlSqlScriptInclude('C:\\deploy\\x.sql', 'db/install.sql', files)).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show more