jcl-parser 2.0.0__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/PKG-INFO +1 -1
  2. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jcl_parser.egg-info/PKG-INFO +1 -1
  3. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jcl_parser.egg-info/SOURCES.txt +1 -0
  4. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/__init__.py +1 -1
  5. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/_job_builder.py +12 -2
  6. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/_post_parse.py +91 -31
  7. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/_statement_parser.py +36 -5
  8. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/condition_parser.py +63 -60
  9. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/continuation_handler.py +6 -6
  10. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/dataset_lifecycle.py +229 -110
  11. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/explosion.py +1 -1
  12. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/lint/rules.py +15 -4
  13. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/lint/runner.py +39 -7
  14. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/model.py +41 -1
  15. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/parser.py +111 -32
  16. jcl_parser-2.0.1/jclparser/program_profiles/_control.py +136 -0
  17. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/base.py +24 -2
  18. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/idcams.py +25 -5
  19. jcl_parser-2.0.1/jclparser/program_profiles/iebgener.py +197 -0
  20. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/ikjeft01.py +11 -3
  21. jcl_parser-2.0.1/jclparser/program_profiles/sort.py +144 -0
  22. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/rc_analysis.py +198 -109
  23. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/system_symbols.py +1 -1
  24. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/validators.py +9 -2
  25. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/pyproject.toml +1 -1
  26. jcl_parser-2.0.0/jclparser/program_profiles/iebgener.py +0 -67
  27. jcl_parser-2.0.0/jclparser/program_profiles/sort.py +0 -74
  28. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/LICENSE +0 -0
  29. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/README.md +0 -0
  30. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jcl_parser.egg-info/dependency_links.txt +0 -0
  31. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jcl_parser.egg-info/entry_points.txt +0 -0
  32. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jcl_parser.egg-info/requires.txt +0 -0
  33. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jcl_parser.egg-info/top_level.txt +0 -0
  34. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/__main__.py +0 -0
  35. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/_model_factories.py +0 -0
  36. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/_source_scanner.py +0 -0
  37. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/builtin_procs.py +0 -0
  38. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/cli.py +0 -0
  39. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/errors.py +0 -0
  40. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/include_loader.py +0 -0
  41. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/jcl_file_type.py +0 -0
  42. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/jes_statements.py +0 -0
  43. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/lint/__init__.py +0 -0
  44. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/lint/base.py +0 -0
  45. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/lint/config.py +0 -0
  46. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/metrics.py +0 -0
  47. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/parameter_ast.py +0 -0
  48. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/parameter_validators.py +0 -0
  49. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/plugins/__init__.py +0 -0
  50. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/plugins/base.py +0 -0
  51. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/proc_loader.py +0 -0
  52. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/profiling.py +0 -0
  53. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/__init__.py +0 -0
  54. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/_invoked.py +0 -0
  55. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/adrdssu.py +0 -0
  56. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/binder.py +0 -0
  57. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/bpxbatch.py +0 -0
  58. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/compilers.py +0 -0
  59. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/db2_batch.py +0 -0
  60. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/dfsrrc00.py +0 -0
  61. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/dsnutilb.py +0 -0
  62. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/ftp.py +0 -0
  63. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/icetool.py +0 -0
  64. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/iebcompr.py +0 -0
  65. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/iebdg.py +0 -0
  66. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/iebptpch.py +0 -0
  67. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/iebupdte.py +0 -0
  68. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/iefbr14.py +0 -0
  69. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/iehinitt.py +0 -0
  70. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/iehlist.py +0 -0
  71. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/iehprogm.py +0 -0
  72. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/irxjcl.py +0 -0
  73. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/program_profiles/registry.py +0 -0
  74. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/py.typed +0 -0
  75. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/sql_detector.py +0 -0
  76. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/jclparser/utils.py +0 -0
  77. {jcl_parser-2.0.0 → jcl_parser-2.0.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
5
  Author-email: JCL Parser Team <team@jclparser.dev>
6
6
  License: AGPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
5
  Author-email: JCL Parser Team <team@jclparser.dev>
6
6
  License: AGPL-3.0-or-later
@@ -45,6 +45,7 @@ jclparser/lint/runner.py
45
45
  jclparser/plugins/__init__.py
46
46
  jclparser/plugins/base.py
47
47
  jclparser/program_profiles/__init__.py
48
+ jclparser/program_profiles/_control.py
48
49
  jclparser/program_profiles/_invoked.py
49
50
  jclparser/program_profiles/adrdssu.py
50
51
  jclparser/program_profiles/base.py
@@ -20,7 +20,7 @@ application, for example::
20
20
  import logging as _logging
21
21
 
22
22
  # Package metadata
23
- __version__ = "2.0.0" # x-release-please-version
23
+ __version__ = "2.0.1" # x-release-please-version
24
24
  __author__ = "JCL Parser Team"
25
25
  __description__ = "Parse IBM JCL scripts into Python objects"
26
26
 
@@ -611,9 +611,19 @@ class JobBuilder:
611
611
  self.pending_jes_typed.append(typed)
612
612
 
613
613
  def _on_output(self, stmt: Statement, line_num: int) -> None:
614
- # OUTPUT statement - JES output descriptor; promote to typed model
614
+ # OUTPUT statement - JES output descriptor; promote to typed model.
615
+ # Coded after an EXEC it is step-level, before the first EXEC
616
+ # job-level (or, in a PROC body, procedure-level): DD OUTPUT=*.name
617
+ # resolution depends on which (#85).
618
+ step = self.current_step
619
+ procedure = self.current_procedure
620
+ output = OutputStatement.from_statement(
621
+ stmt,
622
+ step_name=(step.name or "") if step is not None else None,
623
+ procedure_name=procedure.name if procedure is not None else None,
624
+ )
615
625
  sink = self.job.output_statements if self.job else self.pending_outputs
616
- sink.append(OutputStatement.from_statement(stmt))
626
+ sink.append(output)
617
627
 
618
628
  def _on_null(self, stmt: Statement, line_num: int) -> None:
619
629
  # NULL statement (//) - just ignore, used as delimiter
@@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any
24
24
 
25
25
  from ._model_factories import _has_unresolved_symbol_ref
26
26
  from .errors import JCLStructureError, JCLSyntaxError
27
- from .model import Job, Step, iter_execution_steps
27
+ from .model import Job, OutputStatement, Step, iter_execution_steps
28
28
  from .utils import split_source_lines
29
29
 
30
30
  if TYPE_CHECKING:
@@ -48,64 +48,124 @@ def _extract_output_ref_names(value: Any) -> list[str]:
48
48
  Values that do not start with ``*.`` (e.g. a bare nodename, a qualified
49
49
  ``STEP.PROCSTEP.N`` form without the leading ``*.``, or ``None``) yield
50
50
  an empty list. Callers rely on this to distinguish output-statement
51
- references from other uses of the OUTPUT= parameter.
51
+ references from other uses of the OUTPUT= parameter. The step
52
+ qualifiers are kept by :func:`_extract_output_refs`.
53
+ """
54
+ return [ref[-1] for ref in _extract_output_refs(value)]
55
+
56
+
57
+ def _extract_output_refs(value: Any) -> list[tuple[str, ...]]:
58
+ """Return the ``*.`` OUTPUT references of a DD ``OUTPUT=`` value, qualified.
59
+
60
+ Each reference is the tuple of its uppercased segments::
61
+
62
+ OUTPUT=*.NAME -> [("NAME",)]
63
+ OUTPUT=(*.STEP.NAME,*.N2) -> [("STEP", "NAME"), ("N2",)]
64
+ OUTPUT=*.STEP.PROCSTEP.NAME -> [("STEP", "PROCSTEP", "NAME")]
52
65
  """
53
- names: list[str] = []
54
66
  items: list[str]
55
67
  if isinstance(value, list):
56
68
  items = [str(v) for v in value]
57
69
  elif isinstance(value, str):
58
70
  items = [value]
59
71
  else:
60
- return names
72
+ return []
61
73
 
74
+ refs: list[tuple[str, ...]] = []
62
75
  for item in items:
63
76
  token = item.strip().strip("()")
64
77
  if not token.startswith("*."):
65
- # Qualified form step.procstep.name uses ".name" — last segment after *
66
- if ".*" in token:
67
- # Not a back-reference either
68
- continue
69
- continue
70
- ref = token[2:].strip()
71
- if not ref:
72
78
  continue
73
- # If qualified like STEP.PROCSTEP.NAME, take the final segment.
74
- if "." in ref:
75
- ref = ref.split(".")[-1]
76
- names.append(ref.upper())
77
- return names
79
+ segments = tuple(seg.strip().upper() for seg in token[2:].split("."))
80
+ if segments[-1]:
81
+ refs.append(segments)
82
+ return refs
83
+
84
+
85
+ #: Key of an OUTPUT statement in :class:`_OutputScopes`:
86
+ #: ``(procedure_name, step_name, name)``, uppercased; ``None`` for a level
87
+ #: the statement is not coded in.
88
+ _OutputKey = tuple[str | None, str | None, str]
89
+
90
+
91
+ def _upper(name: str | None) -> str | None:
92
+ return name.upper() if name is not None else None
93
+
94
+
95
+ class _OutputScopes:
96
+ """The job's OUTPUT statements, by the step (and PROC) they are coded in."""
97
+
98
+ def __init__(self, job: Job) -> None:
99
+ self.by_key: dict[_OutputKey, OutputStatement] = {}
100
+ for out in job.output_statements:
101
+ if out.name:
102
+ key = (
103
+ _upper(out.procedure_name),
104
+ _upper(out.step_name),
105
+ out.name.upper(),
106
+ )
107
+ self.by_key[key] = out # last wins if duplicated
108
+ self.instream_procs = {p.name.upper() for p in job.procedures}
109
+ self.step_procs = {
110
+ step.name.upper(): (step.procedure or "").upper()
111
+ for step in iter_execution_steps(job.steps)
112
+ if step.name
113
+ }
114
+
115
+ def checkable(self, ref: tuple[str, ...]) -> bool:
116
+ """False for ``*.step.procstep.name`` into a PROC that is not in-stream.
117
+
118
+ Its OUTPUT statements are not part of the job, so whether the
119
+ reference resolves cannot be told.
120
+ """
121
+ return len(ref) != 3 or self.step_procs.get(ref[0]) in self.instream_procs
122
+
123
+ def resolve(self, ref: tuple[str, ...], step_name: str) -> OutputStatement | None:
124
+ """The OUTPUT statement *ref* names from a DD in job step *step_name*.
125
+
126
+ ``*.name`` is the one in the DD's own step, else the job-level one;
127
+ ``*.step.name`` the one in job step *step*; ``*.step.procstep.name``
128
+ the one in step *procstep* of the in-stream PROC job step *step*
129
+ calls.
130
+ """
131
+ name = ref[-1]
132
+ if len(ref) == 1:
133
+ own = self.by_key.get((None, step_name.upper(), name))
134
+ return own if own is not None else self.by_key.get((None, None, name))
135
+ if len(ref) == 2:
136
+ return self.by_key.get((None, ref[0], name))
137
+ if len(ref) == 3:
138
+ return self.by_key.get((self.step_procs.get(ref[0]), ref[1], name))
139
+ return None
78
140
 
79
141
 
80
142
  def _resolve_output_refs(job: Job) -> None:
81
143
  """Populate ``DDStatement.output_refs`` for every DD that uses ``OUTPUT=*.X``.
82
144
 
83
- Unresolved references (no matching :class:`OutputStatement`) are recorded
84
- on ``job.unresolved_output_refs`` as ``(step_name, ref_name, line_number)``
85
- tuples so :func:`jclparser.validators.validate_job_structure` can emit
86
- warnings without re-scanning the parameter string.
145
+ A reference resolves by scope, not by name alone (#85): see
146
+ :meth:`_OutputScopes.resolve`. Unresolved references (no matching
147
+ :class:`OutputStatement`) are recorded on ``job.unresolved_output_refs``
148
+ as ``(step_name, ref, line_number)`` tuples, *ref* as coded after ``*.``
149
+ (``"NAME"``, ``"STEP.NAME"``), so
150
+ :func:`jclparser.validators.validate_job_structure` can emit warnings
151
+ without re-scanning the parameter string. A reference into a PROC that
152
+ is not in-stream cannot be checked and is neither.
87
153
  """
88
154
  if not job:
89
155
  return
90
- # Index OUTPUT statements by uppercased name (last wins if duplicated).
91
- output_by_name: dict[str, Any] = {
92
- out.name.upper(): out for out in job.output_statements if out.name
93
- }
94
-
95
- from .model import iter_execution_steps
96
-
156
+ scopes = _OutputScopes(job)
97
157
  for step in iter_execution_steps(job.steps):
98
158
  for dd in step.dd_statements:
99
159
  value = dd.parameters.get("OUTPUT")
100
160
  if value is None:
101
161
  continue
102
- for ref in _extract_output_ref_names(value):
103
- target = output_by_name.get(ref)
162
+ for ref in _extract_output_refs(value):
163
+ target = scopes.resolve(ref, step.name or "")
104
164
  if target is not None:
105
165
  dd.output_refs.append(target)
106
- else:
166
+ elif scopes.checkable(ref):
107
167
  job.unresolved_output_refs.append(
108
- (step.name or "", ref, dd.line_number)
168
+ (step.name or "", ".".join(ref), dd.line_number)
109
169
  )
110
170
 
111
171
 
@@ -173,17 +173,36 @@ def _resumes_operands(text: str) -> bool:
173
173
  return "=" in token.split(",")[0]
174
174
 
175
175
 
176
- def _find_comment_start(operands: str) -> int | None:
176
+ def _closes_parens(text: str, depth: int) -> bool:
177
+ """Return True if *text*, read at paren *depth*, brings the depth to 0."""
178
+ in_quotes = False
179
+ for char in text:
180
+ if char == "'":
181
+ in_quotes = not in_quotes
182
+ elif not in_quotes:
183
+ if char == "(":
184
+ depth += 1
185
+ elif char == ")":
186
+ depth -= 1
187
+ if depth <= 0:
188
+ return True
189
+ return False
190
+
191
+
192
+ def _find_comment_start(operands: str, depth: int = 0) -> int | None:
177
193
  """Index of the comment field inside *operands*, or ``None`` if there is none.
178
194
 
179
195
  The operand field ends at the first blank that is outside quotes and
180
196
  outside parentheses; everything after it is the comment field (z/OS JCL
181
- Reference, "Comment field").
197
+ Reference, "Comment field"). Inside a parenthesised list, a blank that
198
+ follows a subparameter's comma also starts a comment when the rest of
199
+ the record neither resumes operands nor closes the list: the list goes
200
+ on in the next record. *depth* is the paren depth the record starts at
201
+ (non-zero for a record continuing an open list).
182
202
  """
183
203
  if " " not in operands and "\t" not in operands:
184
204
  # No blank at all: the operand field runs to the end of the record.
185
205
  return None
186
- depth = 0
187
206
  in_quotes = False
188
207
  i = 0
189
208
  n = len(operands)
@@ -207,6 +226,16 @@ def _find_comment_start(operands: str) -> int | None:
207
226
  i = n - len(rest)
208
227
  continue
209
228
  return i
229
+ elif (
230
+ char.isspace()
231
+ and operands[:i].endswith(",")
232
+ and not _resumes_operands(operands[i:].lstrip())
233
+ and not _closes_parens(operands[i:], depth)
234
+ ):
235
+ # A parenthesised list interrupted after a subparameter's
236
+ # comma may carry a comment (z/OS JCL Reference, "Continuing
237
+ # the operand field"); the list resumes on the next record.
238
+ return i
210
239
  i += 1
211
240
  return None
212
241
 
@@ -240,7 +269,9 @@ def _find_then_end(operands: str) -> int | None:
240
269
  return None
241
270
 
242
271
 
243
- def split_comment_field(line: str, group_operation: str = "") -> tuple[str, str]:
272
+ def split_comment_field(
273
+ line: str, group_operation: str = "", paren_depth: int = 0
274
+ ) -> tuple[str, str]:
244
275
  """Split a JCL statement record into ``(statement, comment)``.
245
276
 
246
277
  In JCL a statement's operand field ends at the first blank outside quotes
@@ -270,7 +301,7 @@ def split_comment_field(line: str, group_operation: str = "") -> tuple[str, str]
270
301
  if cut is None:
271
302
  return line, ""
272
303
  return (line[:start] + operands[:cut]).rstrip(), operands[cut:].strip()
273
- cut = _find_comment_start(operands)
304
+ cut = _find_comment_start(operands, paren_depth)
274
305
  if cut is None:
275
306
  return line, ""
276
307
  return (line[:start] + operands[:cut]).rstrip(), operands[cut:].strip()
@@ -363,6 +363,20 @@ def _tokenize(text: str) -> list[str]: # noqa: C901 -- complexity baseline, spl
363
363
  # ---------------------------------------------------------------------------
364
364
 
365
365
 
366
+ def _split_step_ref(token: str) -> tuple[str | None, str]:
367
+ """Split ``[STEP.[INNER.]]ATTR`` into ``(step_name, ATTR)`` (uppercased).
368
+
369
+ The three-level form ``OUTERST.INNERST.ATTR`` (a step of an exploded
370
+ PROC) yields the step name ``OUTERST.INNERST``.
371
+ """
372
+ if "." not in token:
373
+ return None, token.upper()
374
+ parts = token.split(".", 2) # at most 3 parts
375
+ if len(parts) == 3:
376
+ return f"{parts[0]}.{parts[1]}".upper(), parts[2].upper()
377
+ return parts[0].upper(), parts[1].upper()
378
+
379
+
366
380
  class _Parser:
367
381
  """Internal recursive-descent parser for condition expressions."""
368
382
 
@@ -419,7 +433,7 @@ class _Parser:
419
433
  return NotCondition(self._parse_not())
420
434
  return self._parse_primary()
421
435
 
422
- def _parse_primary(self) -> ConditionNode: # noqa: C901 -- complexity baseline, split when touched
436
+ def _parse_primary(self) -> ConditionNode:
423
437
  tok = self._peek()
424
438
  if tok is None:
425
439
  raise ValueError("Unexpected end of condition expression")
@@ -439,74 +453,63 @@ class _Parser:
439
453
  # produced after PROC explosion; "PROCSTEP.INNERSTEP" is used as the
440
454
  # step-name key in step_results).
441
455
  token = self._consume()
442
- step_name: str | None = None
443
- attr: str
444
-
445
- if "." in token:
446
- parts = token.split(".", 2) # at most 3 parts
447
- if len(parts) == 3:
448
- # Three-level: OUTERST.INNERST.ATTR
449
- step_name = f"{parts[0]}.{parts[1]}".upper()
450
- attr = parts[2].upper()
451
- else:
452
- step_name = parts[0].upper()
453
- attr = parts[1].upper()
454
- else:
455
- attr = token.upper()
456
+ step_name, attr = _split_step_ref(token)
456
457
 
457
458
  if attr == "ABEND":
458
459
  return self._boolean_test(AbendCondition(step_name=step_name))
459
-
460
460
  if attr == "RUN":
461
461
  return self._boolean_test(RunCondition(step_name=step_name))
462
-
463
462
  if attr == "RC":
464
- op_tok = self._peek()
465
- op = _OP_MAP.get(op_tok.upper()) if op_tok else None
466
- if op is None:
467
- # ``RC`` is not a Boolean: without a comparison it tests
468
- # nothing, and reading it as ``RC EQ 0`` invents one.
469
- raise ValueError(f"Expected an operator after RC, got {op_tok!r}")
470
-
471
- self._consume() # consume operator
463
+ return self._rc_comparison(step_name)
464
+ if _RE_DIGITS.match(attr) and step_name is None:
465
+ return self._value_first_comparison(attr)
466
+ raise ValueError(f"Unrecognised condition atom: {token!r}")
472
467
 
473
- val_tok = self._peek()
474
- if val_tok is not None and _RE_DIGITS.match(val_tok):
475
- self._consume()
476
- return ReturnCodeCondition(
477
- step_name=step_name, operator=op, value=int(val_tok)
478
- )
479
-
480
- # A non-numeric value (e.g. an unresolved &SYMBOL) cannot be
481
- # compared; reading it as 0 would invent a different condition.
482
- raise ValueError(f"Expected a numeric RC value, got {val_tok!r}")
483
-
484
- # Bare number first: ``value op [STEP.]RC`` (rare). The operands are
485
- # the other way round, so the operator is mirrored -- ``4 LT RC``
486
- # holds when RC is greater than 4, i.e. ``RC GT 4``.
487
- if _RE_DIGITS.match(attr):
488
- op_tok = self._peek()
489
- op = _OP_MAP.get(op_tok.upper()) if op_tok else None
490
- if op:
491
- self._consume()
492
- # After the operator the user may have written "RC" or a step ref
493
- next_tok = self._peek()
494
- ref = next_tok.upper() if next_tok else ""
495
- if ref == "RC" or ref.endswith(".RC"):
496
- self._consume()
497
- ref_step = ref[: -len(".RC")] if ref != "RC" else None
498
- return ReturnCodeCondition(
499
- step_name=ref_step or None,
500
- operator=_MIRRORED_OP[op],
501
- value=int(attr),
502
- )
503
- raise ValueError(f"Expected RC after {attr} {op_tok}, got {next_tok!r}")
504
- # Fallback: single number, treat as RC EQ value
468
+ def _rc_comparison(self, step_name: str | None) -> ConditionNode:
469
+ """Parse ``op value`` after ``[STEP.]RC``."""
470
+ op_tok = self._peek()
471
+ op = _OP_MAP.get(op_tok.upper()) if op_tok else None
472
+ if op is None:
473
+ # ``RC`` is not a Boolean: without a comparison it tests
474
+ # nothing, and reading it as ``RC EQ 0`` invents one.
475
+ raise ValueError(f"Expected an operator after RC, got {op_tok!r}")
476
+ self._consume() # consume operator
477
+
478
+ val_tok = self._peek()
479
+ if val_tok is not None and _RE_DIGITS.match(val_tok):
480
+ self._consume()
505
481
  return ReturnCodeCondition(
506
- step_name=step_name, operator="EQ", value=int(attr)
482
+ step_name=step_name, operator=op, value=int(val_tok)
507
483
  )
508
-
509
- raise ValueError(f"Unrecognised condition atom: {token!r}")
484
+ # A non-numeric value (e.g. an unresolved &SYMBOL) cannot be
485
+ # compared; reading it as 0 would invent a different condition.
486
+ raise ValueError(f"Expected a numeric RC value, got {val_tok!r}")
487
+
488
+ def _value_first_comparison(self, value: str) -> ConditionNode:
489
+ """Parse ``op [STEP.]RC`` after a leading number (rare).
490
+
491
+ The operands are the other way round, so the operator is mirrored --
492
+ ``4 LT RC`` holds when RC is greater than 4, i.e. ``RC GT 4``. A
493
+ bare number with no comparison tests nothing; reading it as
494
+ ``RC EQ n`` would invent a condition (issue #86, like the bare
495
+ ``RC`` of issue #63), so it is rejected.
496
+ """
497
+ op_tok = self._peek()
498
+ op = _OP_MAP.get(op_tok.upper()) if op_tok else None
499
+ if op is None:
500
+ raise ValueError(f"Expected an operator after {value}, got {op_tok!r}")
501
+ self._consume()
502
+ next_tok = self._peek()
503
+ ref = next_tok.upper() if next_tok else ""
504
+ if ref != "RC" and not ref.endswith(".RC"):
505
+ raise ValueError(f"Expected RC after {value} {op_tok}, got {next_tok!r}")
506
+ self._consume()
507
+ ref_step = ref[: -len(".RC")] if ref != "RC" else None
508
+ return ReturnCodeCondition(
509
+ step_name=ref_step or None,
510
+ operator=_MIRRORED_OP[op],
511
+ value=int(value),
512
+ )
510
513
 
511
514
  def _boolean_test(self, node: ConditionNode) -> ConditionNode:
512
515
  """Consume an optional ``= TRUE`` / ``= FALSE`` after ABEND or RUN."""
@@ -300,18 +300,18 @@ class ContinuationLineHandler:
300
300
  if not next_stripped:
301
301
  break
302
302
 
303
- # A continuation record only has a comment field of its own
304
- # when the statement so far is syntactically closed (the
305
- # blank that starts a comment would otherwise be inside the
306
- # quoted string or parenthesised list being continued).
303
+ # A continuation record has no comment field of its own while
304
+ # a quoted string is being continued (the blank would be inside
305
+ # it). Inside a parenthesised list it may have one after a
306
+ # subparameter's comma, so the open depth is passed along.
307
307
  # The comment is only recorded once the record is known to be
308
308
  # merged: a record that starts the next statement is stripped
309
309
  # (and recorded) again as that statement, which would report
310
310
  # the same comment twice.
311
311
  next_comment = ""
312
- if not cached_ctx.in_quotes and cached_ctx.paren_depth == 0:
312
+ if not cached_ctx.in_quotes:
313
313
  next_line, next_comment = split_comment_field(
314
- next_line, group_operation
314
+ next_line, group_operation, cached_ctx.paren_depth
315
315
  )
316
316
 
317
317
  # Check if next line is a continuation, passing cached context