jcl-parser 2.0.3__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.3 → jcl_parser-2.1.0}/PKG-INFO +2 -2
  2. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jcl_parser.egg-info/PKG-INFO +2 -2
  3. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jcl_parser.egg-info/SOURCES.txt +1 -0
  4. {jcl_parser-2.0.3 → 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.3 → jcl_parser-2.1.0}/jclparser/_job_builder.py +164 -52
  7. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/_model_factories.py +28 -1
  8. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/_post_parse.py +163 -89
  9. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/_source_scanner.py +109 -38
  10. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/_statement_parser.py +72 -7
  11. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/cli.py +32 -4
  12. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/condition_parser.py +18 -12
  13. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/continuation_handler.py +46 -21
  14. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/dataset_lifecycle.py +68 -31
  15. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/errors.py +13 -7
  16. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/explosion.py +390 -169
  17. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/include_loader.py +173 -92
  18. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/lint/rules.py +6 -6
  19. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/metrics.py +4 -1
  20. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/model.py +11 -0
  21. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/parser.py +80 -24
  22. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/plugins/base.py +7 -0
  23. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/proc_loader.py +18 -6
  24. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/_control.py +6 -3
  25. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/_invoked.py +2 -1
  26. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/base.py +8 -3
  27. {jcl_parser-2.0.3 → 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.3 → jcl_parser-2.1.0}/jclparser/program_profiles/icetool.py +2 -1
  30. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/sort.py +65 -9
  31. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/rc_analysis.py +60 -7
  32. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/sql_detector.py +56 -17
  33. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/utils.py +69 -3
  34. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/validators.py +135 -76
  35. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/pyproject.toml +2 -2
  36. jcl_parser-2.0.3/jclparser/program_profiles/ftp.py +0 -59
  37. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/LICENSE +0 -0
  38. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/README.md +0 -0
  39. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jcl_parser.egg-info/dependency_links.txt +0 -0
  40. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jcl_parser.egg-info/entry_points.txt +0 -0
  41. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jcl_parser.egg-info/requires.txt +0 -0
  42. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jcl_parser.egg-info/top_level.txt +0 -0
  43. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/__main__.py +0 -0
  44. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/builtin_procs.py +0 -0
  45. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/jcl_file_type.py +0 -0
  46. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/jes_statements.py +0 -0
  47. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/lint/__init__.py +0 -0
  48. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/lint/base.py +0 -0
  49. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/lint/config.py +0 -0
  50. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/lint/runner.py +0 -0
  51. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/parameter_ast.py +0 -0
  52. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/parameter_validators.py +0 -0
  53. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/plugins/__init__.py +0 -0
  54. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/profiling.py +0 -0
  55. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/__init__.py +0 -0
  56. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/adrdssu.py +0 -0
  57. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/bpxbatch.py +0 -0
  58. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/compilers.py +0 -0
  59. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/db2_batch.py +0 -0
  60. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/dfsrrc00.py +0 -0
  61. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/dsnutilb.py +0 -0
  62. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/idcams.py +0 -0
  63. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iebcompr.py +0 -0
  64. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iebdg.py +0 -0
  65. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iebgener.py +0 -0
  66. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iebptpch.py +0 -0
  67. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iebupdte.py +0 -0
  68. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iefbr14.py +0 -0
  69. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iehinitt.py +0 -0
  70. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iehlist.py +0 -0
  71. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/iehprogm.py +0 -0
  72. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/ikjeft01.py +0 -0
  73. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/irxjcl.py +0 -0
  74. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/program_profiles/registry.py +0 -0
  75. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/py.typed +0 -0
  76. {jcl_parser-2.0.3 → jcl_parser-2.1.0}/jclparser/system_symbols.py +0 -0
  77. {jcl_parser-2.0.3 → 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.3
3
+ Version: 2.1.0
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
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.3
3
+ Version: 2.1.0
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
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.3" # 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
@@ -185,6 +197,9 @@ class JobBuilder:
185
197
  self.pending_conditionals: list[ConditionalBlock] = []
186
198
  self.pending_steps: list[Step | ConditionalBlock] = []
187
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] = []
188
203
  #: The open ``CNTL`` block, whose program control records are collected
189
204
  #: verbatim until its ``ENDCNTL``.
190
205
  self.current_cntl: Statement | None = None
@@ -284,13 +299,17 @@ class JobBuilder:
284
299
  # ------------------------------------------------------------------ #
285
300
 
286
301
  def handle_statement(self, stmt: Statement, line_num: int) -> None:
287
- """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
+ """
288
308
  if stmt.warnings:
289
309
  self._warnings_sink().extend(stmt.warnings)
290
310
 
291
- handler_name = self._HANDLERS.get(stmt.type)
292
- if handler_name is not None:
293
- 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)
294
313
 
295
314
  # ------------------------------------------------------------------ #
296
315
  # Per-statement-type handlers
@@ -327,24 +346,19 @@ class JobBuilder:
327
346
  self.in_joblib_concat = False # JOBLIB concatenation ends at first EXEC
328
347
  self.concat_override_key = None
329
348
 
330
- # Attach any orphaned DDs to this first step
331
- if self.orphaned_dds:
332
- for dd in self.orphaned_dds:
333
- self.current_step.add_dd(dd)
334
- self.orphaned_dds = []
335
-
336
- # Add step to procedure if we're inside PROC/PEND, otherwise to job
337
- if self.conditional_stack:
338
- active_cond, active_branch = self.conditional_stack[-1]
339
- if active_branch == "THEN":
340
- active_cond.add_then_step(self.current_step)
341
- else:
342
- active_cond.add_else_step(self.current_step)
343
- elif self.current_procedure is not None:
344
- 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, []
345
355
  else:
346
- assert self.job is not None
347
- 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)
348
362
 
349
363
  def _on_dd(self, stmt: Statement, line_num: int) -> None:
350
364
  # Create the DD statement
@@ -360,9 +374,12 @@ class JobBuilder:
360
374
  elif not stmt.name and self.in_joblib_concat and self.job is not None:
361
375
  # Empty-name DD concatenating onto the JOBLIB group
362
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)
363
380
  else:
364
381
  self.in_joblib_concat = False
365
- # Store as orphaned DD to be attached to next step
382
+ # Job-level DD: attached to the next job-level step
366
383
  self.orphaned_dds.append(current_dd)
367
384
  elif stmt.name and "." in stmt.name:
368
385
  # This is a DD override for a procedure step
@@ -428,14 +445,43 @@ class JobBuilder:
428
445
  self.job.add_procedure(self.current_procedure)
429
446
  else:
430
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)
431
450
  # Reset current_step since we're now inside procedure
432
451
  self.current_step = None
452
+ self.in_joblib_concat = False
453
+ self.proc_orphaned_dds = []
433
454
 
434
455
  def _on_pend(self, stmt: Statement, line_num: int) -> None:
435
- # 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
436
471
  self.current_procedure = None
437
472
  self.current_step = None
438
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
+
439
485
  def _on_set(self, stmt: Statement, line_num: int) -> None:
440
486
  # SET symbol=value statement. A SET before the JOB statement (or in a
441
487
  # JOB-less member) is buffered rather than dropped; finish() flushes it.
@@ -507,11 +553,26 @@ class JobBuilder:
507
553
  order_value = stmt.parameters["ORDER"]
508
554
  sink = self.job.jcllib_order if self.job else self.pending_jcllib
509
555
  # ORDER parameter might already be parsed as a list
556
+ libs: list[str] = []
510
557
  if isinstance(order_value, list):
511
- sink.extend(order_value)
558
+ libs = [str(lib) for lib in order_value]
512
559
  elif isinstance(order_value, str):
513
- libs = order_value.strip("()").split(",")
514
- 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)
515
576
 
516
577
  def _on_include(self, stmt: Statement, line_num: int) -> None:
517
578
  # INCLUDE MEMBER=name
@@ -554,20 +615,32 @@ class JobBuilder:
554
615
  condition = condition[:-5]
555
616
  return condition
556
617
 
557
- def _attach_conditional(self, conditional: ConditionalBlock) -> None:
558
- """Place a new IF block in the enclosing branch, PROC, job or buffer."""
559
- if self.conditional_stack:
560
- 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
561
634
  if active_branch == "THEN":
562
- active_cond.add_then_step(conditional)
635
+ active_cond.add_then_step(item)
563
636
  else:
564
- active_cond.add_else_step(conditional)
637
+ active_cond.add_else_step(item)
565
638
  elif self.current_procedure is not None:
566
- self.current_procedure.steps.append(conditional)
639
+ self.current_procedure.steps.append(item)
567
640
  elif self.job is not None:
568
- self.job.steps.append(conditional)
641
+ self.job.steps.append(item)
569
642
  else:
570
- self.pending_steps.append(conditional)
643
+ self.pending_steps.append(item)
571
644
 
572
645
  def _open_if(self, stmt: Statement) -> None:
573
646
  """Handle ``IF``: record a new :class:`ConditionalBlock` and enter it."""
@@ -581,14 +654,14 @@ class JobBuilder:
581
654
  self.job.add_conditional(conditional)
582
655
  else:
583
656
  self.pending_conditionals.append(conditional)
584
- self._attach_conditional(conditional)
657
+ self._attach(conditional)
585
658
  self.conditional_stack.append((conditional, "THEN"))
586
659
 
587
660
  def _switch_branch(self, branch: str, line_num: int) -> None:
588
661
  """Handle ``THEN`` / ``ELSE``: make *branch* the open IF's active one."""
589
- if self.conditional_stack:
590
- active_cond, _ = self.conditional_stack.pop()
591
- 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)
592
665
  else:
593
666
  self._warnings_sink().append(
594
667
  f"Line {line_num}: {branch} with no matching IF; ignored"
@@ -596,7 +669,7 @@ class JobBuilder:
596
669
 
597
670
  def _close_if(self, line_num: int) -> None:
598
671
  """Handle ``ENDIF``: leave the innermost open IF block."""
599
- if self.conditional_stack:
672
+ if self._innermost_if() is not None:
600
673
  self.conditional_stack.pop()
601
674
  else:
602
675
  self._warnings_sink().append(
@@ -610,6 +683,13 @@ class JobBuilder:
610
683
  from .jes_statements import parse_jes_control
611
684
 
612
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
+ )
613
693
  if self.dialect == "jes3" and stmt.name.upper() not in _JES3_SLASH_STAR:
614
694
  # JES3 does not process JES2 control statements: keep the text,
615
695
  # but do not interpret it, and say so.
@@ -649,7 +729,7 @@ class JobBuilder:
649
729
  pass
650
730
 
651
731
  def _on_control(self, stmt: Statement, line_num: int) -> None:
652
- """Record a COMMAND / SCHEDULE / CNTL statement verbatim."""
732
+ """Record a COMMAND / SCHEDULE / CNTL / NOTIFY statement verbatim."""
653
733
  # No richer model representation, but they are real statements —
654
734
  # record them verbatim rather than discarding them.
655
735
  if stmt.type == "CNTL":
@@ -668,6 +748,22 @@ class JobBuilder:
668
748
  if self.current_cntl is not None:
669
749
  self.current_cntl.parameters["_records"].append(line)
670
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
+
671
767
  def _on_unknown(self, stmt: Statement, line_num: int) -> None:
672
768
  # Unknown statement type - log but continue processing
673
769
  statement_type_name = stmt.parameters.get("_statement_type", "")
@@ -691,8 +787,6 @@ class JobBuilder:
691
787
  line_number=stmt.line_number,
692
788
  )
693
789
  self.current_step.add_dd(self.current_dd)
694
- self.in_inline_data = True
695
- self.inline_data_lines = []
696
790
  else:
697
791
  # Job-level XMIT has no step to attach a DD to, but the
698
792
  # transmitted data block that follows must still be
@@ -702,12 +796,29 @@ class JobBuilder:
702
796
  # it; clear current_dd so the inline-data-end handler
703
797
  # doesn't misattach it to an unrelated earlier DD.
704
798
  self.current_dd = None
705
- self.in_inline_data = True
706
- self.inline_data_lines = []
707
799
  self._warnings_sink().append(
708
800
  f"Line {stmt.line_number}: job-level XMIT has no step to attach "
709
801
  f"its data to; the transmitted records were discarded"
710
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
711
822
 
712
823
  # ------------------------------------------------------------------ #
713
824
  # Finalisation
@@ -776,7 +887,7 @@ class JobBuilder:
776
887
  return self.job
777
888
  if not self.pending_procedures:
778
889
  raise JCLSyntaxError("No JOB statement found", 0)
779
- job = Job(name="", line_number=0)
890
+ job = Job(name="", line_number=0, is_procedure_member=True)
780
891
  for proc in self.pending_procedures:
781
892
  job.add_procedure(proc)
782
893
  # A JOB-less member (a cataloged procedure) has no job
@@ -814,6 +925,7 @@ class JobBuilder:
814
925
  job.conditionals[:0] = self.pending_conditionals
815
926
  job.steps[:0] = self.pending_steps
816
927
  job.control_statements[:0] = self.pending_control
928
+ job.custom_statements[:0] = self.pending_custom
817
929
 
818
930
  def _flush_pending_sets(self, job: Job) -> None:
819
931
  # Statements seen before the JOB statement (or in a JOB-less member)
@@ -838,8 +950,8 @@ class JobBuilder:
838
950
 
839
951
  def _warn_orphaned_dds(self, job: Job) -> None:
840
952
  # orphaned_dds is only ever non-empty here when the whole job had no
841
- # EXEC statement at all (the first EXEC always flushes and clears the
842
- # 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
843
955
  # discarded, so record what was lost instead of vanishing them silently.
844
956
  if self.orphaned_dds:
845
957
  dd_names = ", ".join(dd.name or "(unnamed)" for dd in self.orphaned_dds)
@@ -23,7 +23,12 @@ from typing import Any
23
23
 
24
24
  from .errors import JCLSyntaxError
25
25
  from .model import DDStatement, Job, Statement, Step
26
- from .utils import MAX_SYMBOL_VALUE_LENGTH, POSITIONAL_PARAM_PREFIX, is_positional_key
26
+ from .utils import (
27
+ MAX_SYMBOL_VALUE_LENGTH,
28
+ POSITIONAL_PARAM_PREFIX,
29
+ is_positional_key,
30
+ jcl_value_text,
31
+ )
27
32
 
28
33
  _SET_SYMBOL_PATTERN = re.compile(r"&&|&([A-Z0-9@#$]+)\.?")
29
34
 
@@ -97,6 +102,23 @@ def _report_set_overflow(
97
102
  warnings.append(f"{prefix}{message}; value left unresolved")
98
103
 
99
104
 
105
+ def _resolve_jcllib_library(library: str, known_symbols: dict[str, str]) -> str | None:
106
+ """Substitute SET symbols into one ``JCLLIB ORDER`` library name.
107
+
108
+ z/OS resolves the SET symbols defined before the JCLLIB statement, so
109
+ *known_symbols* must be the SET table in effect at that statement. Returns
110
+ ``None`` when a ``&symbol`` is still unresolved afterwards (a system symbol
111
+ such as ``&SYSUID``, or a SET coded later); the caller keeps the library as
112
+ written then.
113
+ """
114
+ if "&" not in library:
115
+ return library
116
+ resolved = _resolve_set_value(library, known_symbols)
117
+ if _has_unresolved_symbol_ref(resolved):
118
+ return None
119
+ return resolved
120
+
121
+
100
122
  def _has_unresolved_symbol_ref(
101
123
  value: str, ignore: Callable[[str], bool] | None = None
102
124
  ) -> bool:
@@ -215,6 +237,11 @@ def _create_dd_from_statement(stmt: Statement) -> DDStatement:
215
237
  dataset_name = stmt.parameters.get("DSN")
216
238
  if dataset_name is None:
217
239
  dataset_name = stmt.parameters.get("DSNAME")
240
+ if isinstance(dataset_name, list):
241
+ # ``DSN=(A.B)`` tokenizes to a list; dataset_name is always text, so
242
+ # keep it as coded ("(A.B)") for validate_jcl() to report as an
243
+ # invalid dataset name instead of crashing on it (#121).
244
+ dataset_name = jcl_value_text(dataset_name)
218
245
  disposition = stmt.parameters.get("DISP")
219
246
 
220
247
  return DDStatement(