jcl-parser 2.2.3__tar.gz → 2.3.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 (80) hide show
  1. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/PKG-INFO +1 -1
  2. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jcl_parser.egg-info/PKG-INFO +1 -1
  3. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/__init__.py +1 -1
  4. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/_job_builder.py +24 -4
  5. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/_statement_parser.py +1 -1
  6. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/cli.py +125 -7
  7. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/explosion.py +109 -2
  8. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/include_loader.py +39 -7
  9. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/lineage.py +38 -2
  10. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/dfsrrc00.py +48 -0
  11. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/validators.py +20 -2
  12. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/pyproject.toml +1 -1
  13. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/LICENSE +0 -0
  14. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/README.md +0 -0
  15. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jcl_parser.egg-info/SOURCES.txt +0 -0
  16. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jcl_parser.egg-info/dependency_links.txt +0 -0
  17. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jcl_parser.egg-info/entry_points.txt +0 -0
  18. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jcl_parser.egg-info/requires.txt +0 -0
  19. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jcl_parser.egg-info/top_level.txt +0 -0
  20. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/__main__.py +0 -0
  21. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/_include_origins.py +0 -0
  22. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/_model_factories.py +0 -0
  23. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/_post_parse.py +0 -0
  24. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/_source_scanner.py +0 -0
  25. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/builtin_procs.py +0 -0
  26. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/checks.py +0 -0
  27. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/condition_parser.py +0 -0
  28. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/continuation_handler.py +0 -0
  29. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/dataset_lifecycle.py +0 -0
  30. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/errors.py +0 -0
  31. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/jcl_file_type.py +0 -0
  32. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/jes_statements.py +0 -0
  33. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/lint/__init__.py +0 -0
  34. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/lint/base.py +0 -0
  35. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/lint/config.py +0 -0
  36. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/lint/rules.py +0 -0
  37. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/lint/runner.py +0 -0
  38. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/lint/suppress.py +0 -0
  39. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/metrics.py +0 -0
  40. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/model.py +0 -0
  41. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/parameter_ast.py +0 -0
  42. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/parameter_validators.py +0 -0
  43. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/parser.py +0 -0
  44. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/plugins/__init__.py +0 -0
  45. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/plugins/base.py +0 -0
  46. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/proc_loader.py +0 -0
  47. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/profiling.py +0 -0
  48. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/__init__.py +0 -0
  49. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/_control.py +0 -0
  50. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/_invoked.py +0 -0
  51. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/adrdssu.py +0 -0
  52. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/base.py +0 -0
  53. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/binder.py +0 -0
  54. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/bpxbatch.py +0 -0
  55. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/compilers.py +0 -0
  56. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/db2_batch.py +0 -0
  57. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/dsnutilb.py +0 -0
  58. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/ftp.py +0 -0
  59. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/icetool.py +0 -0
  60. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/idcams.py +0 -0
  61. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iebcompr.py +0 -0
  62. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iebdg.py +0 -0
  63. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iebgener.py +0 -0
  64. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iebptpch.py +0 -0
  65. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iebupdte.py +0 -0
  66. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iefbr14.py +0 -0
  67. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iehinitt.py +0 -0
  68. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iehlist.py +0 -0
  69. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/iehprogm.py +0 -0
  70. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/ikjeft01.py +0 -0
  71. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/irxjcl.py +0 -0
  72. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/registry.py +0 -0
  73. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/program_profiles/sort.py +0 -0
  74. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/py.typed +0 -0
  75. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/rc_analysis.py +0 -0
  76. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/serialization.py +0 -0
  77. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/sql_detector.py +0 -0
  78. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/system_symbols.py +0 -0
  79. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/jclparser/utils.py +0 -0
  80. {jcl_parser-2.2.3 → jcl_parser-2.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.2.3
3
+ Version: 2.3.1
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-Expression: AGPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.2.3
3
+ Version: 2.3.1
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-Expression: AGPL-3.0-or-later
@@ -20,7 +20,7 @@ application, for example::
20
20
  import logging as _logging
21
21
 
22
22
  # Package metadata
23
- __version__ = "2.2.3" # x-release-please-version
23
+ __version__ = "2.3.1" # x-release-please-version
24
24
  __author__ = "JCL Parser Team"
25
25
  __description__ = "Parse IBM JCL scripts into Python objects"
26
26
 
@@ -621,11 +621,31 @@ class JobBuilder:
621
621
  sink.append(member)
622
622
  # Found members are expanded before the parse loop runs, so an
623
623
  # INCLUDE that reaches here could not be loaded.
624
- self._warnings_sink().append(
625
- f"Line {stmt.line_number}: INCLUDE member {member} not found in "
626
- "the JCLLIB / procedure libraries; its statements are missing "
627
- "from the job"
624
+ self._warnings_sink().append(self._include_warning(stmt, str(member)))
625
+
626
+ def _include_warning(self, stmt: Statement, member: str) -> str:
627
+ """The warning for an INCLUDE whose member could not be loaded."""
628
+ line = f"Line {stmt.line_number}: INCLUDE member"
629
+ missing = " its statements are missing from the job"
630
+ if "&" in member:
631
+ from .include_loader import _substitute_member_symbols
632
+
633
+ symbols = (
634
+ {**self.pending_sets, **self.job.set_statements}
635
+ if self.job
636
+ else self.pending_sets
628
637
  )
638
+ resolved = _substitute_member_symbols(member, symbols)
639
+ if "&" in resolved:
640
+ # Not a missing member: its name could not be built (#180).
641
+ return (
642
+ f"{line} {member} uses a symbol with no SET value before "
643
+ f"it;{missing}"
644
+ )
645
+ member = f"{resolved} (from {member})"
646
+ return (
647
+ f"{line} {member} not found in the JCLLIB / procedure libraries;{missing}"
648
+ )
629
649
 
630
650
  def _on_conditional(self, stmt: Statement, line_num: int) -> None:
631
651
  # Conditional logic - track conditionals properly
@@ -803,7 +803,7 @@ def _extract_jcllib_order(content: str) -> list[str]:
803
803
  symbols: dict[str, str] = {}
804
804
  lines = split_source_lines(content)
805
805
  for index, line in enumerate(lines):
806
- _record_set_symbols(line.lstrip(), symbols)
806
+ _record_set_symbols(line.lstrip(), symbols, lines, index + 1)
807
807
  params_part = _jcllib_operands(line)
808
808
  if params_part is None:
809
809
  continue
@@ -119,9 +119,15 @@ def _main(argv: list[str] | None = None) -> int:
119
119
  # ("FILE --explode steps"); plain parse_args() hands the whole positional
120
120
  # run to FILE/query before the flag and then rejects "steps".
121
121
  arg_parser = _build_arg_parser()
122
+ if not _argv:
123
+ # A bare `jcl-parser` used to print only "the following arguments
124
+ # are required: FILE"; show the queries and examples instead.
125
+ arg_parser.print_help()
126
+ return 2
122
127
  args = arg_parser.parse_intermixed_args(argv)
123
128
  if args.profile_output and not args.profile:
124
129
  arg_parser.error("--profile-output requires --profile") # exits 2
130
+ _apply_format(args)
125
131
 
126
132
  source: Path | str
127
133
  if args.file == "-":
@@ -160,6 +166,14 @@ def _main(argv: list[str] | None = None) -> int:
160
166
  return _run_query_with_profile(job, args, prof, source)
161
167
 
162
168
 
169
+ def _apply_format(args: argparse.Namespace) -> None:
170
+ """``--format json|sarif`` sets the older ``--json`` / ``--sarif`` flags."""
171
+ if args.format == "json":
172
+ args.json_output = True
173
+ elif args.format == "sarif":
174
+ args.sarif = True
175
+
176
+
163
177
  def _read_stdin() -> str:
164
178
  """The JCL piped on stdin, decoded like a file (``FILE`` is ``-``)."""
165
179
  return read_source(getattr(sys.stdin, "buffer", sys.stdin))
@@ -196,10 +210,70 @@ def _is_step_or_dd_name(job: Job, token: str) -> bool:
196
210
  )
197
211
 
198
212
 
213
+ #: The query words ``jcl-parser FILE <word>`` understands, for typo hints.
214
+ _QUERY_WORDS = (
215
+ "steps",
216
+ "procs",
217
+ "detect",
218
+ "metrics",
219
+ "lint",
220
+ "datasets",
221
+ "sql",
222
+ "analyze",
223
+ "paths",
224
+ "lineage",
225
+ )
226
+
227
+ _HELP_EPILOG = """\
228
+ queries (the words after FILE):
229
+ (none) job summary; with --json the whole parse as JSON
230
+ steps every step, the program or PROC it runs, its DDs
231
+ procs [PROCNAME] inline/external PROCs; --explode shows the expansion
232
+ STEP one step in detail e.g. STEP010
233
+ STEP.DDNAME one DD statement e.g. STEP010.SYSIN
234
+ DDNAME that DD in every step e.g. SYSOUT
235
+ detect is the file a JOB, a PROC, or neither
236
+ metrics step, DD, PROC and dataset counts, complexity
237
+ lint lint rules (--lint-list lists them; --sarif for CI)
238
+ datasets dataset lifecycle: created, read, deleted, passed
239
+ sql DB2 / SQL usage by step
240
+ analyze [STEP] what each utility step (SORT, IDCAMS, ...) does
241
+ paths which steps can run, from the COND / IF logic
242
+
243
+ FILE may be a directory: 'lineage [dot|mermaid]' (the default) maps
244
+ datasets across its jobs, 'lint' and --validate check every file
245
+ (--glob picks them). FILE '-' reads the JCL from stdin.
246
+
247
+ examples:
248
+ jcl-parser PAYROLL.jcl
249
+ jcl-parser PAYROLL.jcl steps --explode --proc-path ./proclib
250
+ jcl-parser PAYROLL.jcl STEP010.SYSIN
251
+ jcl-parser PAYROLL.jcl lint --fail-on warning
252
+ jcl-parser PAYROLL.jcl --validate --format json
253
+ jcl-parser jobs/ lineage mermaid > lineage.mmd
254
+ jcl-lint jobs/*.jcl --format sarif -o jcl.sarif (several files, for CI)
255
+
256
+ exit codes:
257
+ 0 success
258
+ 1 the file could not be read or parsed, a name was not found, or a check
259
+ reported something (--validate errors, lint findings at --fail-on or
260
+ above, dataset / SQL findings)
261
+ 2 the command line was rejected
262
+
263
+ defaults:
264
+ lint reads [tool.jcl-parser.lint] from the nearest pyproject.toml (or
265
+ --config). External PROCs are loaded only with --proc-path, except by
266
+ --validate, which falls back to the JCL_PROCLIB_PATH variable.
267
+ """
268
+
269
+
199
270
  def _build_arg_parser() -> argparse.ArgumentParser:
200
271
  parser = argparse.ArgumentParser(
201
272
  prog="jcl-parser",
202
- description="Parse and inspect IBM JCL files.",
273
+ usage="jcl-parser FILE [QUERY ...] [options]",
274
+ description="Parse and inspect IBM z/OS JCL: jobs, steps, DDs and PROCs.",
275
+ epilog=_HELP_EPILOG,
276
+ formatter_class=argparse.RawDescriptionHelpFormatter,
203
277
  )
204
278
  parser.add_argument(
205
279
  "file",
@@ -210,15 +284,22 @@ def _build_arg_parser() -> argparse.ArgumentParser:
210
284
  "query",
211
285
  nargs="*",
212
286
  default=[],
213
- help=(
214
- "Query: 'steps', 'procs [PROCNAME]', a step name, STEP.DDNAME, "
215
- "or a DD name to find across all steps; for a directory, "
216
- "'lineage [dot|mermaid]'"
217
- ),
287
+ metavar="QUERY",
288
+ help="What to show (see 'queries' below); none prints the job summary",
218
289
  )
219
290
  parser.add_argument(
220
291
  "--json", action="store_true", dest="json_output", help="Output as JSON"
221
292
  )
293
+ parser.add_argument(
294
+ "--format",
295
+ choices=("text", "json", "sarif"),
296
+ default=None,
297
+ help=(
298
+ "Output format: text (default), json (same as --json) or sarif "
299
+ "(same as --sarif; lint and --validate only). The spelling "
300
+ "cobol-parser and jcl-lint use"
301
+ ),
302
+ )
222
303
  parser.add_argument(
223
304
  "--validate", action="store_true", help="Validate the JCL and report errors"
224
305
  )
@@ -469,7 +550,44 @@ def _cmd_step_or_dd(job: Job, query: str, json_out: bool) -> int:
469
550
  return 0
470
551
 
471
552
  # Fall back to DD name across all steps
472
- return _print_dd_across_steps(job, query.upper(), json_out)
553
+ status = _print_dd_across_steps(job, query.upper(), json_out)
554
+ if status != 0:
555
+ hint = _not_found_hint(job, query)
556
+ if hint:
557
+ print(hint, file=sys.stderr)
558
+ return status
559
+
560
+
561
+ def _not_found_hint(job: Job, query: str) -> str | None:
562
+ """A "did you mean" line for a query that named no step or DD.
563
+
564
+ A mistyped query word (``stpes``) used to read only as "No step or DD
565
+ named 'STPES' found", which sends a new user looking for a step.
566
+ """
567
+ import difflib
568
+
569
+ words = difflib.get_close_matches(query.lower(), _QUERY_WORDS, n=1, cutoff=0.6)
570
+ names = sorted(
571
+ {s.name for s in iter_execution_steps(job.steps) if s.name}
572
+ | {
573
+ dd.name
574
+ for s in iter_execution_steps(job.steps)
575
+ for dd in s.dd_statements
576
+ if dd.name
577
+ }
578
+ )
579
+ close = difflib.get_close_matches(query.upper(), names, n=3, cutoff=0.6)
580
+ # JCL names are uppercase: an uppercase query is a misspelt name first,
581
+ # a lowercase one a misspelt query word first.
582
+ name_hint = "Did you mean: " + ", ".join(close) + "?" if close else None
583
+ word_hint = (
584
+ f"Did you mean the query '{words[0]}'? (see 'jcl-parser --help')"
585
+ if words
586
+ else None
587
+ )
588
+ if query == query.upper():
589
+ return name_hint or word_hint
590
+ return word_hint or name_hint
473
591
 
474
592
 
475
593
  def _cmd_directory(path: Path, args: argparse.Namespace) -> int:
@@ -1219,6 +1219,104 @@ def is_exec_override_keyword(key: str) -> bool:
1219
1219
  return key.upper().partition(".")[0] in _EXEC_OVERRIDE_KEYWORDS
1220
1220
 
1221
1221
 
1222
+ _SYMBOL_REFERENCE = re.compile(r"&&|&([A-Z@#$][A-Z0-9@#$]{0,7})", re.IGNORECASE)
1223
+ _INCLUDE_STATEMENT = re.compile(r"^//\S*\s+INCLUDE\s", re.IGNORECASE | re.MULTILINE)
1224
+
1225
+
1226
+ def _referenced_symbols(value: Any, found: set[str], seen: set[int]) -> None:
1227
+ """Add every ``&NAME`` written anywhere in *value* to *found*.
1228
+
1229
+ Walks strings, dicts, lists and the model dataclasses (steps, DDs,
1230
+ conditional blocks) a procedure body is made of. ``&&`` is an escaped
1231
+ ampersand, not a reference.
1232
+ """
1233
+ if isinstance(value, str):
1234
+ for match in _SYMBOL_REFERENCE.finditer(value):
1235
+ if match.group(1):
1236
+ found.add(match.group(1).upper())
1237
+ return
1238
+ if id(value) in seen:
1239
+ return
1240
+ seen.add(id(value))
1241
+ if isinstance(value, dict):
1242
+ for item in value.values():
1243
+ _referenced_symbols(item, found, seen)
1244
+ elif isinstance(value, (list, tuple)):
1245
+ for item in value:
1246
+ _referenced_symbols(item, found, seen)
1247
+ elif isinstance(value, (Step, DDStatement, ConditionalBlock)):
1248
+ for name, item in vars(value).items():
1249
+ if name not in ("comments", "inline_data", "original_proc_call"):
1250
+ _referenced_symbols(item, found, seen)
1251
+
1252
+
1253
+ def call_override_symbols(call: Step) -> set[str]:
1254
+ """Every ``&NAME`` in the overrides coded with PROC call *call*.
1255
+
1256
+ The overriding and added DDs after the call (and their concatenations)
1257
+ and the values of its keyword overrides (``PARM.PS1='&X'``) are merged
1258
+ into the procedure, so the call's symbolics are substituted there too
1259
+ (issue #176).
1260
+ """
1261
+ found: set[str] = set()
1262
+ _referenced_symbols(
1263
+ [
1264
+ call.dd_statements,
1265
+ list(call.dd_overrides.values()),
1266
+ list(call.dd_override_concatenations.values()),
1267
+ [
1268
+ value
1269
+ for key, value in call.parameters.items()
1270
+ if is_exec_override_keyword(key)
1271
+ ],
1272
+ ],
1273
+ found,
1274
+ set(),
1275
+ )
1276
+ return found
1277
+
1278
+
1279
+ def unused_proc_symbols(
1280
+ call: Step, procedure: Procedure, call_references: set[str] | None = None
1281
+ ) -> list[str]:
1282
+ """The symbolics PROC call *call* assigns that *procedure* never uses.
1283
+
1284
+ A symbolic coded on ``EXEC procname`` that is neither declared on the
1285
+ PROC statement nor referenced as ``&NAME`` in the procedure body is a
1286
+ JCL error on z/OS (``IEFC657I THE SYMBOL x WAS NOT USED``), and the
1287
+ expansion silently runs with the PROC's defaults (issue #172). EXEC
1288
+ keyword overrides (``PARM``, ``COND.PS1``, ...) are not symbolics. A
1289
+ reference in the call's overriding or added DDs, or in a keyword
1290
+ override's value, counts as a use (issue #176); *call_references* gives
1291
+ those references when the call's overrides were already substituted
1292
+ (default: :func:`call_override_symbols` of *call*).
1293
+
1294
+ Returns an empty list when the body cannot be fully checked: a procedure
1295
+ with no steps (unloaded), or one whose text has an ``INCLUDE`` (its
1296
+ members' references are not known before explosion).
1297
+ """
1298
+ if not procedure.steps:
1299
+ return []
1300
+ if procedure.source_text and _INCLUDE_STATEMENT.search(procedure.source_text):
1301
+ return []
1302
+ known = {name.upper() for name in procedure.parameters}
1303
+ _referenced_symbols(
1304
+ [procedure.steps, procedure.set_statements, procedure.parameters],
1305
+ known,
1306
+ set(),
1307
+ )
1308
+ if procedure.source_text:
1309
+ _referenced_symbols(procedure.source_text, known, set())
1310
+ known |= call_override_symbols(call) if call_references is None else call_references
1311
+ return [
1312
+ key
1313
+ for key in call.parameters
1314
+ if not is_exec_override_keyword(key)
1315
+ and key.upper() != "PROC"
1316
+ and key.upper() not in known
1317
+ ]
1318
+
1319
+
1222
1320
  def _substitute_proc_call_parameters(
1223
1321
  parameters: dict[str, Any],
1224
1322
  scope: SymbolScope,
@@ -1800,8 +1898,9 @@ class _IncludeExpandingLoader(ProcedureLoader):
1800
1898
  from .utils import split_source_lines
1801
1899
 
1802
1900
  seen = dict(symbols) if symbols is not None else {}
1803
- for line in split_source_lines(expanded_text):
1804
- _record_set_symbols(line.rstrip(), seen)
1901
+ lines = split_source_lines(expanded_text)
1902
+ for index, line in enumerate(lines):
1903
+ _record_set_symbols(line.rstrip(), seen, lines, index + 1)
1805
1904
  missing = _extract_include_member(line.rstrip())
1806
1905
  if not missing:
1807
1906
  continue
@@ -2099,6 +2198,8 @@ def _explode_step(
2099
2198
  # against the job's library order. Pass an immutable tuple: loaders key their
2100
2199
  # caches on the library list, so a hashable value keeps that contract safe.
2101
2200
  _libs = tuple(jcllib_order) if jcllib_order else None
2201
+ # Read before loading resolves the call's overrides (issue #176).
2202
+ call_references = call_override_symbols(step)
2102
2203
  procedure = _load_called_procedure(
2103
2204
  step, proc_loader, _libs, base_scope, call_scope, strict_symbols, warnings
2104
2205
  )
@@ -2121,6 +2222,12 @@ def _explode_step(
2121
2222
  )
2122
2223
 
2123
2224
  _apply_call_overrides(expanded, step, procedure.name or proc_name, warnings)
2225
+ unused = unused_proc_symbols(step, procedure, call_references)
2226
+ if unused and warnings is not None:
2227
+ warnings.append(
2228
+ f"EXEC {step.name or 'PROC'}: symbol(s) {', '.join(unused)} not used "
2229
+ f"by PROC {procedure.name or proc_name} (IEFC657I); its defaults apply"
2230
+ )
2124
2231
  _record_expansion(
2125
2232
  step, procedure, overrides, expanded, depth, audit_records, expansion_map
2126
2233
  )
@@ -26,6 +26,7 @@ import errno
26
26
  import logging
27
27
  import os
28
28
  from abc import ABC, abstractmethod
29
+ from collections.abc import Sequence
29
30
  from pathlib import Path
30
31
  from typing import NamedTuple
31
32
 
@@ -335,21 +336,22 @@ class _IncludeExpander:
335
336
  for index, line in enumerate(lines):
336
337
  expanded = None
337
338
  if index not in data_indices:
338
- expanded = self._expand_statement(line, index + 1, depth, active)
339
+ expanded = self._expand_statement(lines, index, depth, active)
339
340
  if expanded is None:
340
341
  expanded = [(line, LineOrigin(None, index + 1))]
341
342
  result.extend(expanded)
342
343
  return result
343
344
 
344
345
  def _expand_statement(
345
- self, line: str, line_number: int, depth: int, active: frozenset[str]
346
+ self, lines: list[str], index: int, depth: int, active: frozenset[str]
346
347
  ) -> list[_Entry] | None:
347
- """The expansion of *line* if it is a resolvable INCLUDE, else None."""
348
+ """The expansion of ``lines[index]`` if it is a resolvable INCLUDE."""
349
+ line_number = index + 1
348
350
  # Preserve leading whitespace: only strip the line terminator so that
349
351
  # in-stream data lines (which may contain '//' after leading spaces)
350
352
  # are never mistaken for JCL statements.
351
- statement_line = line.rstrip("\r\n")
352
- _record_set_symbols(statement_line, self.symbols)
353
+ statement_line = lines[index].rstrip("\r\n")
354
+ _record_set_symbols(statement_line, self.symbols, lines, index + 1)
353
355
  member = _extract_include_member(statement_line)
354
356
  if member is None:
355
357
  return None
@@ -413,12 +415,19 @@ def _member_origin(origin: LineOrigin, member: str, include_line: int) -> LineOr
413
415
  return LineOrigin(origin.member, include_line, origin.member_line)
414
416
 
415
417
 
416
- def _record_set_symbols(line: str, symbols: dict[str, str]) -> None:
418
+ def _record_set_symbols(
419
+ line: str,
420
+ symbols: dict[str, str],
421
+ lines: Sequence[str] = (),
422
+ follow: int = 0,
423
+ ) -> None:
417
424
  """Record the assignments of a ``// SET`` statement *line* into *symbols*.
418
425
 
419
426
  Values are resolved against the symbols already recorded, mirroring the
420
427
  parser's left-to-right SET evaluation. Lines that are not a SET statement
421
- are ignored.
428
+ are ignored. A SET whose operand field ends in a comma is continued on the
429
+ following ``//`` records: *lines* from index *follow* on (the record after
430
+ *line*) are read for its remaining assignments (#180).
422
431
  """
423
432
  if not line.startswith("//") or line.startswith("//*"):
424
433
  return
@@ -436,6 +445,7 @@ def _record_set_symbols(line: str, symbols: dict[str, str]) -> None:
436
445
  from .utils import is_positional_key, jcl_value_text
437
446
 
438
447
  statement, _ = split_comment_field(line)
448
+ statement = _continue_operands(statement, lines, follow)
439
449
  stmt = parse_statement(statement)
440
450
  if stmt is None or stmt.type != "SET":
441
451
  return
@@ -445,6 +455,28 @@ def _record_set_symbols(line: str, symbols: dict[str, str]) -> None:
445
455
  symbols[key.upper()] = _resolve_set_value(jcl_value_text(value), symbols)
446
456
 
447
457
 
458
+ def _continue_operands(statement: str, lines: Sequence[str], follow: int) -> str:
459
+ """Append the operands of the continuation records of *statement*.
460
+
461
+ A statement whose operand field ends in a comma continues on the next
462
+ ``//`` record that has a blank in column 3; its operand field (up to the
463
+ first blank) is appended. ``//*`` comment records in between are skipped.
464
+ """
465
+ from ._statement_parser import split_comment_field
466
+
467
+ statement = statement.rstrip()
468
+ while statement.endswith(",") and follow < len(lines):
469
+ cont = lines[follow].rstrip("\r\n")
470
+ follow += 1
471
+ if cont.startswith("//*"):
472
+ continue
473
+ if not cont.startswith("// ") or not cont[2:].strip():
474
+ break
475
+ operands, _ = split_comment_field("// " + cont[2:].lstrip())
476
+ statement += operands[2:].strip()
477
+ return statement
478
+
479
+
448
480
  def _substitute_member_symbols(member: str, symbols: dict[str, str]) -> str:
449
481
  """Resolve ``&SYMBOL`` references in an INCLUDE member name.
450
482
 
@@ -22,7 +22,8 @@ share a load library or DBRMLIB do not depend on one another (issue #145). The
22
22
  A DD's role comes from its ``DISP`` status: ``NEW`` / ``MOD`` write the
23
23
  dataset, ``SHR`` / ``OLD`` read it (``OLD`` may also update it in place, which
24
24
  DISP alone can't tell). A normal disposition of ``DELETE`` makes any status
25
- but ``NEW`` a read (a delete runs after the dataset's producer), and
25
+ but ``NEW`` a read (a delete runs after the dataset's producer and after
26
+ every other job that reads or writes it), and
26
27
  ``(NEW,DELETE)`` -- a scratch dataset -- neither. Temporary datasets (``&&TEMP``), ``NULLFILE``,
27
28
  ``SYSOUT`` and DDs without a ``DISP`` are left out; a ``DSN=*.STEP.DD``
28
29
  backward reference counts as the dataset it points at.
@@ -93,6 +94,9 @@ class DatasetUse:
93
94
  dataset: str
94
95
  disp: str
95
96
  line_number: int = 0
97
+ #: The DD deletes the dataset (normal disposition ``DELETE``, any status
98
+ #: but ``NEW``); it is a read that must also follow every other use.
99
+ deletes: bool = False
96
100
 
97
101
  def to_dict(self) -> dict[str, Any]:
98
102
  return {
@@ -102,6 +106,7 @@ class DatasetUse:
102
106
  "dataset": self.dataset,
103
107
  "disp": self.disp,
104
108
  "line_number": self.line_number,
109
+ "deletes": self.deletes,
105
110
  }
106
111
 
107
112
 
@@ -131,7 +136,8 @@ class DatasetLineage:
131
136
 
132
137
  @dataclass(frozen=True)
133
138
  class JobDependency:
134
- """*consumer* reads datasets that *producer* writes."""
139
+ """*consumer* reads datasets that *producer* writes, or deletes datasets
140
+ that *producer* also reads or writes (it must run after that job)."""
135
141
 
136
142
  producer: str
137
143
  consumer: str
@@ -257,6 +263,7 @@ def _record_uses(job_id: str, job: Job, datasets: dict[str, DatasetLineage]) ->
257
263
  dataset=dsn,
258
264
  disp=evt.disp_status,
259
265
  line_number=evt.line_number,
266
+ deletes=evt.disp_normal == "DELETE" and evt.disp_status != "NEW",
260
267
  )
261
268
  role = _role(evt.disp_status, evt.disp_normal)
262
269
  if role == "write":
@@ -313,12 +320,41 @@ def _dependencies(datasets: Mapping[str, DatasetLineage]) -> list[JobDependency]
313
320
  labels = edges.setdefault((producer, consumer), [])
314
321
  if label not in labels:
315
322
  labels.append(label)
323
+ _add_delete_edges(datasets, edges)
316
324
  return [
317
325
  JobDependency(producer, consumer, tuple(sorted(keys)))
318
326
  for (producer, consumer), keys in sorted(edges.items())
319
327
  ]
320
328
 
321
329
 
330
+ def _add_delete_edges(
331
+ datasets: Mapping[str, DatasetLineage], edges: dict[tuple[str, str], list[str]]
332
+ ) -> None:
333
+ """A job that deletes a dataset runs after every other job that uses it.
334
+
335
+ The producer edge is already there (a delete is a read); this adds one
336
+ from each other reader of the dataset -- or of its library or a member
337
+ of it -- so the deleter cannot be ordered before a job that still needs
338
+ the dataset (#179). Two jobs that each delete a dataset the other reads
339
+ depend on each other, a cycle.
340
+ """
341
+ for deleted, lineage in datasets.items():
342
+ deleters = _unique(u.job for u in lineage.readers if u.deletes)
343
+ if not deleters:
344
+ continue
345
+ for used, other in datasets.items():
346
+ if used != deleted and not _feeds(used, deleted):
347
+ continue
348
+ # Label the edge with the more specific name (the member).
349
+ label = deleted if deleted != library_of(deleted) else used
350
+ for user in _unique([*other.reader_jobs, *other.writer_jobs]):
351
+ for deleter in deleters:
352
+ if user != deleter:
353
+ labels = edges.setdefault((user, deleter), [])
354
+ if label not in labels:
355
+ labels.append(label)
356
+
357
+
322
358
  def _run_order(
323
359
  jobs: list[str], dependencies: list[JobDependency]
324
360
  ) -> tuple[list[str], list[str]]:
@@ -13,12 +13,29 @@ comma-delimited sub-parameter of PARM:
13
13
  The profile extracts the application program and PSB name from PARM so that
14
14
  ``analyze_step``/``analyze_job`` can report what application is actually
15
15
  running, not just the region controller.
16
+
17
+ That layout holds only for the batch regions (BMP, DLI, DBB). A message
18
+ processing region codes ``PARM=(MSG,&CL1&CL2&CL3&CL4,...)``: PARM[1] is the
19
+ four three-digit transaction classes it serves and it names no program. For
20
+ MSG, IFP and any other region type, only the region type (and, for MSG, the
21
+ classes) is reported (issue #173).
16
22
  """
17
23
 
18
24
  from __future__ import annotations
19
25
 
26
+ from ..model import Step
20
27
  from .base import ProgramAnalysis, ProgramProfile
21
28
 
29
+ #: Region types whose PARM[1] / PARM[2] are the application program and PSB.
30
+ _BATCH_REGION_TYPES = frozenset({"BMP", "DLI", "DBB"})
31
+
32
+
33
+ def _transaction_classes(field: str) -> list[str]:
34
+ """Split an MPP's ``&CL1&CL2&CL3&CL4`` field into its three-digit classes."""
35
+ if not field.isdigit() or len(field) % 3:
36
+ return []
37
+ return [field[i : i + 3] for i in range(0, len(field), 3)]
38
+
22
39
 
23
40
  class DFSRRC00Profile(ProgramProfile):
24
41
  program_names = frozenset({"DFSRRC00", "DFSRRC02"})
@@ -42,6 +59,9 @@ class DFSRRC00Profile(ProgramProfile):
42
59
  parts = [p.strip() for p in parm.split(",")]
43
60
 
44
61
  region_type = parts[0].upper() if parts and parts[0] else ""
62
+ if region_type and region_type not in _BATCH_REGION_TYPES:
63
+ return _online_region_analysis(step, region_type, parts, parm)
64
+
45
65
  app_program = parts[1].upper() if len(parts) > 1 and parts[1] else ""
46
66
  psb_name = parts[2].upper() if len(parts) > 2 and parts[2] else ""
47
67
 
@@ -67,3 +87,31 @@ class DFSRRC00Profile(ProgramProfile):
67
87
  notes=notes,
68
88
  raw_control=parm or None,
69
89
  )
90
+
91
+
92
+ def _online_region_analysis(
93
+ step: Step, region_type: str, parts: list[str], parm: str
94
+ ) -> ProgramAnalysis:
95
+ """A region whose PARM names no program (MSG, IFP, ...): issue #173."""
96
+ notes = [f"IMS region type: {region_type}"]
97
+ classes = (
98
+ _transaction_classes(parts[1])
99
+ if region_type == "MSG" and len(parts) > 1
100
+ else []
101
+ )
102
+ summary = f"IMS {region_type} region ({step.program})"
103
+ if classes:
104
+ summary += f", classes {' '.join(classes)}"
105
+ notes.append(f"transaction classes: {' '.join(classes)}")
106
+ notes.append(
107
+ f"an IMS {region_type} region runs the transactions scheduled to it; "
108
+ "its PARM names no application program"
109
+ )
110
+ return ProgramAnalysis(
111
+ program=step.program or "DFSRRC00",
112
+ step_name=step.name or "",
113
+ summary=summary,
114
+ dataset_events=[],
115
+ notes=notes,
116
+ raw_control=parm or None,
117
+ )
@@ -321,7 +321,10 @@ def validate_backward_references(job: Job) -> list[ValidationError]:
321
321
 
322
322
 
323
323
  def validate_procedure_references(job: Job) -> list[ValidationError]:
324
- """Warn on ``EXEC procname`` calls whose PROC was not found.
324
+ """Check ``EXEC procname`` calls against the PROCs they call.
325
+
326
+ A symbolic the call assigns that the PROC never declares or references
327
+ is an ERROR (``IEFC657I``, issue #172).
325
328
 
326
329
  :func:`jclparser.parse_jcl` adds every PROC it can load (in-stream,
327
330
  JCLLIB / system PROCLIB under the base path, built-in) to
@@ -330,9 +333,24 @@ def validate_procedure_references(job: Job) -> list[ValidationError]:
330
333
  a WARNING, not an ERROR (issue #116). A strict parse of a job with a
331
334
  ``JCLLIB ORDER`` still fails on it before this runs.
332
335
  """
336
+ from .explosion import unused_proc_symbols
337
+
333
338
  errors: list[ValidationError] = []
334
339
  for step in iter_execution_steps(job.steps):
335
- if step.procedure and not job.get_procedure(step.procedure):
340
+ procedure = job.get_procedure(step.procedure) if step.procedure else None
341
+ if procedure is not None:
342
+ # A symbolic the PROC never declares or references (IEFC657I).
343
+ for name in unused_proc_symbols(step, procedure):
344
+ errors.append(
345
+ ValidationError(
346
+ f"Step '{step.name or ''}': symbol {name} is not used by "
347
+ f"procedure '{step.procedure}' (IEFC657I); the "
348
+ "procedure's defaults apply",
349
+ ErrorType.SEMANTIC_ERROR,
350
+ line_number=step.line_number,
351
+ )
352
+ )
353
+ if step.procedure and procedure is None:
336
354
  errors.append(
337
355
  ValidationError(
338
356
  f"Procedure '{step.procedure}' not found in-stream or in "
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jcl-parser"
7
- version = "2.2.3"
7
+ version = "2.3.1"
8
8
  description = "Parse IBM JCL (Job Control Language) scripts into Python objects"
9
9
  authors = [
10
10
  {name = "Samuel Dion", email = "sam94dion@gmail.com"}
File without changes
File without changes
File without changes