jcl-parser 2.0.2__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/PKG-INFO +3 -3
  2. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/PKG-INFO +3 -3
  3. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/SOURCES.txt +1 -0
  4. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/__init__.py +17 -2
  5. jcl_parser-2.1.0/jclparser/_include_origins.py +126 -0
  6. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_job_builder.py +179 -52
  7. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_model_factories.py +28 -1
  8. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_post_parse.py +163 -89
  9. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_source_scanner.py +109 -38
  10. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_statement_parser.py +72 -7
  11. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/cli.py +32 -4
  12. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/condition_parser.py +18 -12
  13. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/continuation_handler.py +46 -21
  14. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/dataset_lifecycle.py +68 -31
  15. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/errors.py +13 -7
  16. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/explosion.py +437 -166
  17. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/include_loader.py +173 -92
  18. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/rules.py +6 -6
  19. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/metrics.py +4 -1
  20. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/model.py +16 -4
  21. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/parser.py +80 -24
  22. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/plugins/base.py +7 -0
  23. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/proc_loader.py +18 -6
  24. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/_control.py +6 -3
  25. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/_invoked.py +2 -1
  26. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/base.py +8 -3
  27. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/binder.py +2 -1
  28. jcl_parser-2.1.0/jclparser/program_profiles/ftp.py +187 -0
  29. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/icetool.py +2 -1
  30. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/sort.py +65 -9
  31. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/rc_analysis.py +60 -7
  32. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/sql_detector.py +102 -12
  33. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/utils.py +69 -3
  34. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/validators.py +135 -76
  35. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/pyproject.toml +3 -3
  36. jcl_parser-2.0.2/jclparser/program_profiles/ftp.py +0 -59
  37. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/LICENSE +0 -0
  38. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/README.md +0 -0
  39. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/dependency_links.txt +0 -0
  40. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/entry_points.txt +0 -0
  41. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/requires.txt +0 -0
  42. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/top_level.txt +0 -0
  43. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/__main__.py +0 -0
  44. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/builtin_procs.py +0 -0
  45. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/jcl_file_type.py +0 -0
  46. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/jes_statements.py +0 -0
  47. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/__init__.py +0 -0
  48. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/base.py +0 -0
  49. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/config.py +0 -0
  50. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/runner.py +0 -0
  51. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/parameter_ast.py +0 -0
  52. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/parameter_validators.py +0 -0
  53. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/plugins/__init__.py +0 -0
  54. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/profiling.py +0 -0
  55. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/__init__.py +0 -0
  56. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/adrdssu.py +0 -0
  57. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/bpxbatch.py +0 -0
  58. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/compilers.py +0 -0
  59. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/db2_batch.py +0 -0
  60. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/dfsrrc00.py +0 -0
  61. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/dsnutilb.py +0 -0
  62. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/idcams.py +0 -0
  63. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebcompr.py +0 -0
  64. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebdg.py +0 -0
  65. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebgener.py +0 -0
  66. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebptpch.py +0 -0
  67. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebupdte.py +0 -0
  68. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iefbr14.py +0 -0
  69. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iehinitt.py +0 -0
  70. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iehlist.py +0 -0
  71. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iehprogm.py +0 -0
  72. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/ikjeft01.py +0 -0
  73. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/irxjcl.py +0 -0
  74. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/registry.py +0 -0
  75. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/py.typed +0 -0
  76. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/system_symbols.py +0 -0
  77. {jcl_parser-2.0.2 → jcl_parser-2.1.0}/setup.cfg +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
- Author-email: JCL Parser Team <team@jclparser.dev>
5
+ Author-email: Samuel Dion <sam94dion@gmail.com>
6
6
  License: AGPL-3.0-or-later
7
7
  Project-URL: Homepage, https://github.com/landparty/jcl-parser
8
8
  Project-URL: Repository, https://github.com/landparty/jcl-parser
9
- Project-URL: Documentation, https://github.com/landparty/jcl-parser/tree/main/docs
9
+ Project-URL: Documentation, https://github.com/landparty/jcl-parser/tree/main/.okf
10
10
  Project-URL: Bug Reports, https://github.com/landparty/jcl-parser/issues
11
11
  Keywords: jcl,mainframe,parser,ibm,job-control-language
12
12
  Classifier: Development Status :: 5 - Production/Stable
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
- Author-email: JCL Parser Team <team@jclparser.dev>
5
+ Author-email: Samuel Dion <sam94dion@gmail.com>
6
6
  License: AGPL-3.0-or-later
7
7
  Project-URL: Homepage, https://github.com/landparty/jcl-parser
8
8
  Project-URL: Repository, https://github.com/landparty/jcl-parser
9
- Project-URL: Documentation, https://github.com/landparty/jcl-parser/tree/main/docs
9
+ Project-URL: Documentation, https://github.com/landparty/jcl-parser/tree/main/.okf
10
10
  Project-URL: Bug Reports, https://github.com/landparty/jcl-parser/issues
11
11
  Keywords: jcl,mainframe,parser,ibm,job-control-language
12
12
  Classifier: Development Status :: 5 - Production/Stable
@@ -9,6 +9,7 @@ jcl_parser.egg-info/requires.txt
9
9
  jcl_parser.egg-info/top_level.txt
10
10
  jclparser/__init__.py
11
11
  jclparser/__main__.py
12
+ jclparser/_include_origins.py
12
13
  jclparser/_job_builder.py
13
14
  jclparser/_model_factories.py
14
15
  jclparser/_post_parse.py
@@ -20,7 +20,7 @@ application, for example::
20
20
  import logging as _logging
21
21
 
22
22
  # Package metadata
23
- __version__ = "2.0.2" # x-release-please-version
23
+ __version__ = "2.1.0" # x-release-please-version
24
24
  __author__ = "JCL Parser Team"
25
25
  __description__ = "Parse IBM JCL scripts into Python objects"
26
26
 
@@ -66,7 +66,9 @@ from .include_loader import (
66
66
  DictMemberLoader,
67
67
  IncludeMemberLoader,
68
68
  JCLLIBMemberLoader,
69
+ LineOrigin,
69
70
  resolve_includes,
71
+ resolve_includes_with_origins,
70
72
  )
71
73
  from .jcl_file_type import (
72
74
  FileType,
@@ -100,7 +102,16 @@ from .lint import (
100
102
  to_sarif as lint_to_sarif,
101
103
  )
102
104
  from .metrics import JobMetrics, compute_metrics
103
- from .model import DDStatement, Job, OutputStatement, Parameter, Statement, Step
105
+ from .model import (
106
+ ConditionalBlock,
107
+ DDStatement,
108
+ Job,
109
+ OutputStatement,
110
+ Parameter,
111
+ Statement,
112
+ Step,
113
+ iter_execution_steps,
114
+ )
104
115
  from .parser import parse_jcl, parse_statement, validate_jcl
105
116
  from .plugins import (
106
117
  ParserConfig,
@@ -172,6 +183,8 @@ __all__ = [ # noqa: RUF022 — grouped by API tier, not alphabetically
172
183
  "Parameter",
173
184
  "Statement",
174
185
  "OutputStatement",
186
+ "ConditionalBlock",
187
+ "iter_execution_steps",
175
188
  "JCLParseError",
176
189
  "JCLSyntaxError",
177
190
  "JCLValidationError",
@@ -230,7 +243,9 @@ __all__ = [ # noqa: RUF022 — grouped by API tier, not alphabetically
230
243
  "IncludeMemberLoader",
231
244
  "DictMemberLoader",
232
245
  "JCLLIBMemberLoader",
246
+ "LineOrigin",
233
247
  "resolve_includes",
248
+ "resolve_includes_with_origins",
234
249
  # ── Tier 2: Dataset lifecycle analysis ───────────────────────────────────
235
250
  "DatasetEvent",
236
251
  "analyze_dataset_lifecycle",
@@ -0,0 +1,126 @@
1
+ """Report INCLUDE-expanded line numbers against the main source (issue #118).
2
+
3
+ ``// INCLUDE MEMBER=`` statements are expanded before the source is scanned,
4
+ so every line number the parser computes counts the inlined member lines.
5
+ :class:`IncludeLineMap` translates such a number back: a main-source line to
6
+ its own line, a member line to the line of the ``// INCLUDE`` statement in
7
+ the main source (the outermost one, for a nested INCLUDE). Messages about a
8
+ member line also name the member and the line within it::
9
+
10
+ Line 2 (INCLUDE member COMMON, line 3): EXEC statement missing ...
11
+
12
+ The parse runs on the expanded text unchanged; :func:`_parse_jcl_core
13
+ <jclparser.parser._parse_jcl_core>` applies the translation once, at the end
14
+ (:meth:`IncludeLineMap.relabel_job`) or to the exception that ends the parse
15
+ (:meth:`IncludeLineMap.relabel_error`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import dataclasses
21
+ import re
22
+ from collections.abc import Iterable
23
+ from typing import Any
24
+
25
+ from .errors import JCLParseError
26
+ from .include_loader import LineOrigin
27
+ from .model import Job, Procedure
28
+
29
+ _LEADING_LINE = re.compile(r"^Line (\d+)")
30
+ _INNER_LINE = re.compile(r"\bon line (\d+)\b")
31
+
32
+
33
+ class IncludeLineMap:
34
+ """Line number translation for one INCLUDE-expanded source."""
35
+
36
+ def __init__(self, origins: list[LineOrigin]) -> None:
37
+ self._origins = origins
38
+
39
+ def origin(self, line_number: int) -> LineOrigin | None:
40
+ """Where expanded line *line_number* came from (None if unknown)."""
41
+ if 1 <= line_number <= len(self._origins):
42
+ return self._origins[line_number - 1]
43
+ return None
44
+
45
+ def translate(self, line_number: int) -> int:
46
+ """The main-source line of expanded line *line_number*."""
47
+ origin = self.origin(line_number)
48
+ return origin.line if origin is not None else line_number
49
+
50
+ def note(self, line_number: int) -> str:
51
+ """``"INCLUDE member M, line k"`` for a member line, else ``""``."""
52
+ origin = self.origin(line_number)
53
+ if origin is None or origin.member is None:
54
+ return ""
55
+ return f"INCLUDE member {origin.member}, line {origin.member_line}"
56
+
57
+ def label(self, line_number: int) -> str:
58
+ """``"Line N"``, plus the member note for a member line."""
59
+ note = self.note(line_number)
60
+ main_line = self.translate(line_number)
61
+ return f"Line {main_line} ({note})" if note else f"Line {main_line}"
62
+
63
+ def relabel_text(self, text: str) -> str:
64
+ """Translate the ``Line N`` prefix (and ``on line N``) of a message."""
65
+ text = _LEADING_LINE.sub(lambda m: self.label(int(m.group(1))), text, 1)
66
+ return _INNER_LINE.sub(
67
+ lambda m: f"on line {self.translate(int(m.group(1)))}", text
68
+ )
69
+
70
+ def relabel_error(self, exc: JCLParseError) -> None:
71
+ """Translate the line number of the exception that ended the parse."""
72
+ if exc.line_number <= 0:
73
+ return
74
+ exc.include_note = self.note(exc.line_number)
75
+ exc.line_number = self.translate(exc.line_number)
76
+ exc.args = (exc._format_message(),)
77
+
78
+ def relabel_job(self, job: Job, own_procedures: Iterable[Procedure]) -> None:
79
+ """Translate every line number on *job* and in its warnings.
80
+
81
+ Only the procedures in *own_procedures* -- the in-stream ones, read
82
+ from this source -- are relabelled: an external or built-in PROC
83
+ carries line numbers of its own library member.
84
+ """
85
+ own = {id(proc) for proc in own_procedures}
86
+ skip = {id(p) for p in job.procedures if id(p) not in own}
87
+ self._relabel_object(job, skip)
88
+ job.warnings[:] = [self.relabel_text(w) for w in job.warnings]
89
+ job.unresolved_output_refs[:] = [
90
+ (step_name, ref, self.translate(line))
91
+ for step_name, ref, line in job.unresolved_output_refs
92
+ ]
93
+
94
+ def _relabel_object(self, obj: Any, seen: set[int]) -> None:
95
+ """Translate ``line_number`` on *obj* and every model object under it."""
96
+ if id(obj) in seen:
97
+ return
98
+ seen.add(id(obj))
99
+ if isinstance(obj, (list, tuple)):
100
+ for item in obj:
101
+ self._relabel_object(item, seen)
102
+ elif isinstance(obj, dict):
103
+ for item in obj.values():
104
+ self._relabel_object(item, seen)
105
+ elif _is_model_object(obj):
106
+ self._relabel_fields(obj, seen)
107
+
108
+ def _relabel_fields(self, obj: Any, seen: set[int]) -> None:
109
+ line_number = getattr(obj, "line_number", None)
110
+ if isinstance(line_number, int) and line_number > 0:
111
+ try:
112
+ obj.line_number = self.translate(line_number)
113
+ except dataclasses.FrozenInstanceError:
114
+ pass
115
+ for field in dataclasses.fields(obj):
116
+ if not field.name.startswith("_") and field.name != "line_number":
117
+ self._relabel_object(getattr(obj, field.name), seen)
118
+
119
+
120
+ def _is_model_object(obj: Any) -> bool:
121
+ """A jclparser dataclass instance (not a class, not a foreign type)."""
122
+ return (
123
+ dataclasses.is_dataclass(obj)
124
+ and not isinstance(obj, type)
125
+ and type(obj).__module__.startswith("jclparser")
126
+ )
@@ -34,6 +34,7 @@ from ._model_factories import (
34
34
  _create_step_from_statement,
35
35
  _exec_has_program_spec,
36
36
  _has_unresolved_symbol_ref,
37
+ _resolve_jcllib_library,
37
38
  _resolve_set_value,
38
39
  )
39
40
  from ._post_parse import (
@@ -59,6 +60,8 @@ from .utils import (
59
60
  instream_delimiter,
60
61
  is_positional_key,
61
62
  jcl_value_text,
63
+ jes_xmit_delimiter,
64
+ xmit_delimiter,
62
65
  )
63
66
 
64
67
  logger = logging.getLogger(__name__)
@@ -103,8 +106,8 @@ class JobBuilder:
103
106
 
104
107
  # Statement type -> name of its ``_on_<type>`` handler. Every type in
105
108
  # ``_STATEMENT_TYPES`` must appear here, even when its handler is a
106
- # deliberate no-op (NULL, ENDCNTL): an unlisted type is dropped
107
- # without a trace.
109
+ # deliberate no-op (NULL, ENDCNTL). Any other type -- a custom type
110
+ # returned by a StatementPlugin -- is kept in ``Job.custom_statements``.
108
111
  _HANDLERS: ClassVar[dict[str, str]] = {
109
112
  "JOB": "_on_job",
110
113
  "EXEC": "_on_exec",
@@ -122,10 +125,12 @@ class JobBuilder:
122
125
  "JES": "_on_jes",
123
126
  "OUTPUT": "_on_output",
124
127
  "NULL": "_on_null",
125
- # No richer model representation for COMMAND / SCHEDULE / CNTL.
128
+ # No richer model representation for COMMAND / SCHEDULE / CNTL /
129
+ # NOTIFY (z/OS 2.3).
126
130
  "COMMAND": "_on_control",
127
131
  "SCHEDULE": "_on_control",
128
132
  "CNTL": "_on_control",
133
+ "NOTIFY": "_on_control",
129
134
  "ENDCNTL": "_on_endcntl",
130
135
  "UNKNOWN": "_on_unknown",
131
136
  "XMIT": "_on_xmit",
@@ -151,8 +156,15 @@ class JobBuilder:
151
156
  self.pending_procedures: list[Procedure] = []
152
157
  self.current_delimiter = "/*"
153
158
  self.current_terminates_on_slash = False
154
- #: Track DDs that appear before first EXEC
159
+ #: Job-level DDs that appear before the first job-level EXEC (e.g.
160
+ #: ``SYSCHK``/``JOBCAT``); attached to that step, never to a step of an
161
+ #: in-stream PROC defined in between (#103).
155
162
  self.orphaned_dds: list[DDStatement] = []
163
+ #: DDs inside a PROC/PEND body before the procedure's first EXEC.
164
+ self.proc_orphaned_dds: list[DDStatement] = []
165
+ #: ``len(conditional_stack)`` when the open PROC began: IF blocks at
166
+ #: or below it belong to the enclosing job, not to the procedure.
167
+ self.proc_cond_depth = 0
156
168
  #: True while accumulating JOBLIB concatenation lines
157
169
  self.in_joblib_concat = False
158
170
  #: Qualified name (``PROCSTEP.DDNAME``) of the override DD the current
@@ -174,6 +186,10 @@ class JobBuilder:
174
186
  #: only if some SET symbol is redefined after it was first set.
175
187
  self.job_level_steps: list[Step] = []
176
188
  self.set_redefined = False
189
+ #: Steps of in-stream / cataloged procedures with the procedure they
190
+ #: belong to; finish() prunes their ``set_symbols`` snapshot the same
191
+ #: way (against the procedure's own final SET table).
192
+ self.procedure_steps: list[tuple[Procedure, Step]] = []
177
193
  self.pending_exported: set[str] = set()
178
194
  self.pending_jcllib: list[str] = []
179
195
  self.pending_includes: list[str] = []
@@ -181,6 +197,9 @@ class JobBuilder:
181
197
  self.pending_conditionals: list[ConditionalBlock] = []
182
198
  self.pending_steps: list[Step | ConditionalBlock] = []
183
199
  self.pending_control: list[Statement] = []
200
+ #: Statements of a type the builder has no handler for (e.g. a custom
201
+ #: type returned by a StatementPlugin) seen before the JOB statement.
202
+ self.pending_custom: list[Statement] = []
184
203
  #: The open ``CNTL`` block, whose program control records are collected
185
204
  #: verbatim until its ``ENDCNTL``.
186
205
  self.current_cntl: Statement | None = None
@@ -280,13 +299,17 @@ class JobBuilder:
280
299
  # ------------------------------------------------------------------ #
281
300
 
282
301
  def handle_statement(self, stmt: Statement, line_num: int) -> None:
283
- """Record statement warnings, then dispatch to the type handler."""
302
+ """Record statement warnings, then dispatch to the type handler.
303
+
304
+ A statement whose type has no handler in :attr:`_HANDLERS` (e.g. a
305
+ custom type returned by a :class:`~jclparser.plugins.StatementPlugin`)
306
+ is kept verbatim in :attr:`Job.custom_statements` (#130).
307
+ """
284
308
  if stmt.warnings:
285
309
  self._warnings_sink().extend(stmt.warnings)
286
310
 
287
- handler_name = self._HANDLERS.get(stmt.type)
288
- if handler_name is not None:
289
- getattr(self, handler_name)(stmt, line_num)
311
+ handler_name = self._HANDLERS.get(stmt.type, "_on_custom")
312
+ getattr(self, handler_name)(stmt, line_num)
290
313
 
291
314
  # ------------------------------------------------------------------ #
292
315
  # Per-statement-type handlers
@@ -315,27 +338,27 @@ class JobBuilder:
315
338
  **self.job.set_statements,
316
339
  }
317
340
  self.job_level_steps.append(self.current_step)
341
+ elif self.current_procedure is not None:
342
+ # A SET inside the procedure is in effect from where it is coded,
343
+ # so this step sees the procedure's SET values as they stand now.
344
+ self.current_step.set_symbols = dict(self.current_procedure.set_statements)
345
+ self.procedure_steps.append((self.current_procedure, self.current_step))
318
346
  self.in_joblib_concat = False # JOBLIB concatenation ends at first EXEC
319
347
  self.concat_override_key = None
320
348
 
321
- # Attach any orphaned DDs to this first step
322
- if self.orphaned_dds:
323
- for dd in self.orphaned_dds:
324
- self.current_step.add_dd(dd)
325
- self.orphaned_dds = []
326
-
327
- # Add step to procedure if we're inside PROC/PEND, otherwise to job
328
- if self.conditional_stack:
329
- active_cond, active_branch = self.conditional_stack[-1]
330
- if active_branch == "THEN":
331
- active_cond.add_then_step(self.current_step)
332
- else:
333
- active_cond.add_else_step(self.current_step)
334
- elif self.current_procedure is not None:
335
- self.current_procedure.steps.append(self.current_step)
349
+ # Attach DDs seen before the first EXEC of this scope to this step.
350
+ # Job-level DDs (SYSCHK, JOBCAT, ...) wait for the first job-level
351
+ # EXEC: a step inside an in-stream PROC definition must never pick
352
+ # them up, or every expansion of the PROC would copy them (#103).
353
+ if self.current_procedure is not None:
354
+ pending_dds, self.proc_orphaned_dds = self.proc_orphaned_dds, []
336
355
  else:
337
- assert self.job is not None
338
- self.job.add_step(self.current_step)
356
+ pending_dds, self.orphaned_dds = self.orphaned_dds, []
357
+ for dd in pending_dds:
358
+ self.current_step.add_dd(dd)
359
+
360
+ # Add step to the open IF branch, the PROC being defined, or the job
361
+ self._attach(self.current_step)
339
362
 
340
363
  def _on_dd(self, stmt: Statement, line_num: int) -> None:
341
364
  # Create the DD statement
@@ -351,9 +374,12 @@ class JobBuilder:
351
374
  elif not stmt.name and self.in_joblib_concat and self.job is not None:
352
375
  # Empty-name DD concatenating onto the JOBLIB group
353
376
  self.job.joblib_dds.append(current_dd)
377
+ elif self.current_procedure is not None:
378
+ # DD in a PROC body before its first EXEC
379
+ self.proc_orphaned_dds.append(current_dd)
354
380
  else:
355
381
  self.in_joblib_concat = False
356
- # Store as orphaned DD to be attached to next step
382
+ # Job-level DD: attached to the next job-level step
357
383
  self.orphaned_dds.append(current_dd)
358
384
  elif stmt.name and "." in stmt.name:
359
385
  # This is a DD override for a procedure step
@@ -419,14 +445,43 @@ class JobBuilder:
419
445
  self.job.add_procedure(self.current_procedure)
420
446
  else:
421
447
  self.pending_procedures.append(self.current_procedure)
448
+ # IF blocks already open belong to the job, not to this procedure.
449
+ self.proc_cond_depth = len(self.conditional_stack)
422
450
  # Reset current_step since we're now inside procedure
423
451
  self.current_step = None
452
+ self.in_joblib_concat = False
453
+ self.proc_orphaned_dds = []
424
454
 
425
455
  def _on_pend(self, stmt: Statement, line_num: int) -> None:
426
- # End of procedure definition - stop tracking
456
+ # End of procedure definition - stop tracking. PEND closes the PROC
457
+ # scope: an IF opened in the body and never closed by ENDIF ends here
458
+ # (it is already attached to the procedure), so later job steps are
459
+ # not read into it (#102).
460
+ procedure = self.current_procedure
461
+ if procedure is not None:
462
+ while len(self.conditional_stack) > self.proc_cond_depth:
463
+ cond, _branch = self.conditional_stack.pop()
464
+ self._warnings_sink().append(
465
+ f"Line {cond.line_number}: IF block in PROC {procedure.name} "
466
+ f"has no matching ENDIF; closed at PEND (line {line_num})"
467
+ )
468
+ self._warn_proc_orphaned_dds(procedure, line_num)
469
+ self.proc_orphaned_dds = []
470
+ self.proc_cond_depth = 0
427
471
  self.current_procedure = None
428
472
  self.current_step = None
429
473
 
474
+ def _warn_proc_orphaned_dds(self, procedure: Procedure, line_num: int) -> None:
475
+ """Report DDs of a PROC body that had no EXEC step to attach to."""
476
+ if self.proc_orphaned_dds:
477
+ dd_names = ", ".join(
478
+ dd.name or "(unnamed)" for dd in self.proc_orphaned_dds
479
+ )
480
+ self._warnings_sink().append(
481
+ f"Line {line_num}: PROC {procedure.name} has DD statement(s) "
482
+ f"but no EXEC step to attach them to; discarded: {dd_names}"
483
+ )
484
+
430
485
  def _on_set(self, stmt: Statement, line_num: int) -> None:
431
486
  # SET symbol=value statement. A SET before the JOB statement (or in a
432
487
  # JOB-less member) is buffered rather than dropped; finish() flushes it.
@@ -498,11 +553,26 @@ class JobBuilder:
498
553
  order_value = stmt.parameters["ORDER"]
499
554
  sink = self.job.jcllib_order if self.job else self.pending_jcllib
500
555
  # ORDER parameter might already be parsed as a list
556
+ libs: list[str] = []
501
557
  if isinstance(order_value, list):
502
- sink.extend(order_value)
558
+ libs = [str(lib) for lib in order_value]
503
559
  elif isinstance(order_value, str):
504
- libs = order_value.strip("()").split(",")
505
- sink.extend([lib.strip() for lib in libs])
560
+ libs = [lib.strip() for lib in order_value.strip("()").split(",")]
561
+ # Only the SETs coded before this statement are in effect here.
562
+ symbols = (
563
+ {**self.pending_sets, **self.job.set_statements}
564
+ if self.job
565
+ else self.pending_sets
566
+ )
567
+ for lib in libs:
568
+ resolved = _resolve_jcllib_library(lib, symbols)
569
+ if resolved is None:
570
+ self._warnings_sink().append(
571
+ f"Line {stmt.line_number}: JCLLIB ORDER library {lib} "
572
+ "contains an unresolved symbol; kept as written"
573
+ )
574
+ resolved = lib
575
+ sink.append(resolved)
506
576
 
507
577
  def _on_include(self, stmt: Statement, line_num: int) -> None:
508
578
  # INCLUDE MEMBER=name
@@ -545,20 +615,32 @@ class JobBuilder:
545
615
  condition = condition[:-5]
546
616
  return condition
547
617
 
548
- def _attach_conditional(self, conditional: ConditionalBlock) -> None:
549
- """Place a new IF block in the enclosing branch, PROC, job or buffer."""
550
- if self.conditional_stack:
551
- active_cond, active_branch = self.conditional_stack[-1]
618
+ def _innermost_if(self) -> tuple[ConditionalBlock, str] | None:
619
+ """The innermost IF block open in the current scope, or None.
620
+
621
+ Inside a PROC/PEND body only IF blocks opened in that body count; an
622
+ IF the job opened before the PROC statement stays the job's (#102).
623
+ """
624
+ floor = self.proc_cond_depth if self.current_procedure is not None else 0
625
+ if len(self.conditional_stack) > floor:
626
+ return self.conditional_stack[-1]
627
+ return None
628
+
629
+ def _attach(self, item: Step | ConditionalBlock) -> None:
630
+ """Place a step or IF block in the open branch, PROC, job or buffer."""
631
+ open_if = self._innermost_if()
632
+ if open_if is not None:
633
+ active_cond, active_branch = open_if
552
634
  if active_branch == "THEN":
553
- active_cond.add_then_step(conditional)
635
+ active_cond.add_then_step(item)
554
636
  else:
555
- active_cond.add_else_step(conditional)
637
+ active_cond.add_else_step(item)
556
638
  elif self.current_procedure is not None:
557
- self.current_procedure.steps.append(conditional)
639
+ self.current_procedure.steps.append(item)
558
640
  elif self.job is not None:
559
- self.job.steps.append(conditional)
641
+ self.job.steps.append(item)
560
642
  else:
561
- self.pending_steps.append(conditional)
643
+ self.pending_steps.append(item)
562
644
 
563
645
  def _open_if(self, stmt: Statement) -> None:
564
646
  """Handle ``IF``: record a new :class:`ConditionalBlock` and enter it."""
@@ -572,14 +654,14 @@ class JobBuilder:
572
654
  self.job.add_conditional(conditional)
573
655
  else:
574
656
  self.pending_conditionals.append(conditional)
575
- self._attach_conditional(conditional)
657
+ self._attach(conditional)
576
658
  self.conditional_stack.append((conditional, "THEN"))
577
659
 
578
660
  def _switch_branch(self, branch: str, line_num: int) -> None:
579
661
  """Handle ``THEN`` / ``ELSE``: make *branch* the open IF's active one."""
580
- if self.conditional_stack:
581
- active_cond, _ = self.conditional_stack.pop()
582
- self.conditional_stack.append((active_cond, branch))
662
+ open_if = self._innermost_if()
663
+ if open_if is not None:
664
+ self.conditional_stack[-1] = (open_if[0], branch)
583
665
  else:
584
666
  self._warnings_sink().append(
585
667
  f"Line {line_num}: {branch} with no matching IF; ignored"
@@ -587,7 +669,7 @@ class JobBuilder:
587
669
 
588
670
  def _close_if(self, line_num: int) -> None:
589
671
  """Handle ``ENDIF``: leave the innermost open IF block."""
590
- if self.conditional_stack:
672
+ if self._innermost_if() is not None:
591
673
  self.conditional_stack.pop()
592
674
  else:
593
675
  self._warnings_sink().append(
@@ -601,6 +683,13 @@ class JobBuilder:
601
683
  from .jes_statements import parse_jes_control
602
684
 
603
685
  typed = parse_jes_control(stmt.name, raw_params, stmt.line_number)
686
+ if stmt.name.upper() == "XMIT":
687
+ # The records that follow are a job stream for another node,
688
+ # not JCL of this job; they end at /* or DLM= (issue #117).
689
+ self.current_dd = None
690
+ self._start_transmitted_data(
691
+ jes_xmit_delimiter(raw_params), stmt.line_number
692
+ )
604
693
  if self.dialect == "jes3" and stmt.name.upper() not in _JES3_SLASH_STAR:
605
694
  # JES3 does not process JES2 control statements: keep the text,
606
695
  # but do not interpret it, and say so.
@@ -640,7 +729,7 @@ class JobBuilder:
640
729
  pass
641
730
 
642
731
  def _on_control(self, stmt: Statement, line_num: int) -> None:
643
- """Record a COMMAND / SCHEDULE / CNTL statement verbatim."""
732
+ """Record a COMMAND / SCHEDULE / CNTL / NOTIFY statement verbatim."""
644
733
  # No richer model representation, but they are real statements —
645
734
  # record them verbatim rather than discarding them.
646
735
  if stmt.type == "CNTL":
@@ -659,6 +748,22 @@ class JobBuilder:
659
748
  if self.current_cntl is not None:
660
749
  self.current_cntl.parameters["_records"].append(line)
661
750
 
751
+ def _on_custom(self, stmt: Statement, line_num: int) -> None:
752
+ """Keep a statement of a type the builder has no handler for.
753
+
754
+ Such statements come from a :class:`~jclparser.plugins.StatementPlugin`
755
+ returning a custom ``Statement.type``; they used to be dropped
756
+ silently (#130).
757
+ """
758
+ logger.debug(
759
+ "no handler for statement type %r at line %d; kept in "
760
+ "Job.custom_statements",
761
+ stmt.type,
762
+ line_num,
763
+ )
764
+ sink = self.job.custom_statements if self.job else self.pending_custom
765
+ sink.append(stmt)
766
+
662
767
  def _on_unknown(self, stmt: Statement, line_num: int) -> None:
663
768
  # Unknown statement type - log but continue processing
664
769
  statement_type_name = stmt.parameters.get("_statement_type", "")
@@ -682,8 +787,6 @@ class JobBuilder:
682
787
  line_number=stmt.line_number,
683
788
  )
684
789
  self.current_step.add_dd(self.current_dd)
685
- self.in_inline_data = True
686
- self.inline_data_lines = []
687
790
  else:
688
791
  # Job-level XMIT has no step to attach a DD to, but the
689
792
  # transmitted data block that follows must still be
@@ -693,12 +796,29 @@ class JobBuilder:
693
796
  # it; clear current_dd so the inline-data-end handler
694
797
  # doesn't misattach it to an unrelated earlier DD.
695
798
  self.current_dd = None
696
- self.in_inline_data = True
697
- self.inline_data_lines = []
698
799
  self._warnings_sink().append(
699
800
  f"Line {stmt.line_number}: job-level XMIT has no step to attach "
700
801
  f"its data to; the transmitted records were discarded"
701
802
  )
803
+ self._start_transmitted_data(xmit_delimiter(stmt.parameters), stmt.line_number)
804
+
805
+ def _start_transmitted_data(self, delimiter: Any, line_num: int) -> None:
806
+ """Open the data block of an XMIT / ``/*XMIT`` statement.
807
+
808
+ The transmitted records are a job stream for another node, so a
809
+ ``//`` record never ends them: only ``/*`` or the ``DLM=`` delimiter
810
+ does (the pre-scan in ``_merge_source_lines`` uses the same rule).
811
+ """
812
+ if not isinstance(delimiter, str):
813
+ raise JCLSyntaxError(
814
+ f"DLM= must be a one- or two-character delimiter, got "
815
+ f"{jcl_value_text(delimiter)}",
816
+ line_num,
817
+ )
818
+ self.in_inline_data = True
819
+ self.inline_data_lines = []
820
+ self.current_delimiter = delimiter
821
+ self.current_terminates_on_slash = False
702
822
 
703
823
  # ------------------------------------------------------------------ #
704
824
  # Finalisation
@@ -767,7 +887,7 @@ class JobBuilder:
767
887
  return self.job
768
888
  if not self.pending_procedures:
769
889
  raise JCLSyntaxError("No JOB statement found", 0)
770
- job = Job(name="", line_number=0)
890
+ job = Job(name="", line_number=0, is_procedure_member=True)
771
891
  for proc in self.pending_procedures:
772
892
  job.add_procedure(proc)
773
893
  # A JOB-less member (a cataloged procedure) has no job
@@ -805,6 +925,7 @@ class JobBuilder:
805
925
  job.conditionals[:0] = self.pending_conditionals
806
926
  job.steps[:0] = self.pending_steps
807
927
  job.control_statements[:0] = self.pending_control
928
+ job.custom_statements[:0] = self.pending_custom
808
929
 
809
930
  def _flush_pending_sets(self, job: Job) -> None:
810
931
  # Statements seen before the JOB statement (or in a JOB-less member)
@@ -820,11 +941,17 @@ class JobBuilder:
820
941
  for k, v in (step.set_symbols or {}).items()
821
942
  ):
822
943
  step.set_symbols = None
944
+ for procedure, step in self.procedure_steps:
945
+ if all(
946
+ procedure.set_statements.get(k) == v
947
+ for k, v in (step.set_symbols or {}).items()
948
+ ):
949
+ step.set_symbols = None
823
950
 
824
951
  def _warn_orphaned_dds(self, job: Job) -> None:
825
952
  # orphaned_dds is only ever non-empty here when the whole job had no
826
- # EXEC statement at all (the first EXEC always flushes and clears the
827
- # list) -- those DD statements have nowhere to attach and are
953
+ # job-level EXEC statement at all (the first one always flushes and
954
+ # clears the list) -- those DD statements have nowhere to attach and are
828
955
  # discarded, so record what was lost instead of vanishing them silently.
829
956
  if self.orphaned_dds:
830
957
  dd_names = ", ".join(dd.name or "(unnamed)" for dd in self.orphaned_dds)