jcl-parser 2.1.0__tar.gz → 2.2.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 (86) hide show
  1. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/PKG-INFO +69 -11
  2. jcl_parser-2.2.0/README.md +115 -0
  3. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jcl_parser.egg-info/PKG-INFO +69 -11
  4. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jcl_parser.egg-info/SOURCES.txt +4 -0
  5. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jcl_parser.egg-info/entry_points.txt +1 -0
  6. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jcl_parser.egg-info/requires.txt +3 -1
  7. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/__init__.py +29 -4
  8. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/_include_origins.py +3 -0
  9. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/_job_builder.py +76 -5
  10. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/_statement_parser.py +0 -9
  11. jcl_parser-2.2.0/jclparser/checks.py +382 -0
  12. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/cli.py +458 -200
  13. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/condition_parser.py +55 -64
  14. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/continuation_handler.py +17 -3
  15. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/errors.py +18 -0
  16. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/explosion.py +103 -79
  17. jcl_parser-2.2.0/jclparser/lineage.py +498 -0
  18. jcl_parser-2.2.0/jclparser/lint/__init__.py +47 -0
  19. jcl_parser-2.2.0/jclparser/lint/config.py +189 -0
  20. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/lint/rules.py +114 -4
  21. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/lint/runner.py +62 -36
  22. jcl_parser-2.2.0/jclparser/lint/suppress.py +81 -0
  23. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/model.py +68 -4
  24. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/parameter_ast.py +4 -2
  25. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/parameter_validators.py +58 -50
  26. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/parser.py +8 -3
  27. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/profiling.py +5 -1
  28. jcl_parser-2.2.0/jclparser/program_profiles/adrdssu.py +116 -0
  29. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/binder.py +22 -4
  30. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/iefbr14.py +9 -2
  31. jcl_parser-2.2.0/jclparser/program_profiles/iehlist.py +87 -0
  32. jcl_parser-2.2.0/jclparser/program_profiles/iehprogm.py +94 -0
  33. jcl_parser-2.2.0/jclparser/serialization.py +330 -0
  34. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/utils.py +19 -0
  35. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/validators.py +128 -0
  36. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/pyproject.toml +9 -6
  37. jcl_parser-2.1.0/README.md +0 -58
  38. jcl_parser-2.1.0/jclparser/lint/__init__.py +0 -28
  39. jcl_parser-2.1.0/jclparser/lint/config.py +0 -39
  40. jcl_parser-2.1.0/jclparser/program_profiles/adrdssu.py +0 -72
  41. jcl_parser-2.1.0/jclparser/program_profiles/iehlist.py +0 -54
  42. jcl_parser-2.1.0/jclparser/program_profiles/iehprogm.py +0 -108
  43. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/LICENSE +0 -0
  44. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jcl_parser.egg-info/dependency_links.txt +0 -0
  45. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jcl_parser.egg-info/top_level.txt +0 -0
  46. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/__main__.py +0 -0
  47. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/_model_factories.py +0 -0
  48. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/_post_parse.py +0 -0
  49. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/_source_scanner.py +0 -0
  50. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/builtin_procs.py +0 -0
  51. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/dataset_lifecycle.py +0 -0
  52. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/include_loader.py +0 -0
  53. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/jcl_file_type.py +0 -0
  54. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/jes_statements.py +0 -0
  55. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/lint/base.py +0 -0
  56. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/metrics.py +0 -0
  57. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/plugins/__init__.py +0 -0
  58. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/plugins/base.py +0 -0
  59. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/proc_loader.py +0 -0
  60. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/__init__.py +0 -0
  61. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/_control.py +0 -0
  62. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/_invoked.py +0 -0
  63. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/base.py +0 -0
  64. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/bpxbatch.py +0 -0
  65. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/compilers.py +0 -0
  66. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/db2_batch.py +0 -0
  67. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/dfsrrc00.py +0 -0
  68. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/dsnutilb.py +0 -0
  69. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/ftp.py +0 -0
  70. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/icetool.py +0 -0
  71. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/idcams.py +0 -0
  72. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/iebcompr.py +0 -0
  73. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/iebdg.py +0 -0
  74. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/iebgener.py +0 -0
  75. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/iebptpch.py +0 -0
  76. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/iebupdte.py +0 -0
  77. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/iehinitt.py +0 -0
  78. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/ikjeft01.py +0 -0
  79. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/irxjcl.py +0 -0
  80. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/registry.py +0 -0
  81. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/program_profiles/sort.py +0 -0
  82. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/py.typed +0 -0
  83. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/rc_analysis.py +0 -0
  84. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/sql_detector.py +0 -0
  85. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/jclparser/system_symbols.py +0 -0
  86. {jcl_parser-2.1.0 → jcl_parser-2.2.0}/setup.cfg +0 -0
@@ -1,17 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
5
  Author-email: Samuel Dion <sam94dion@gmail.com>
6
- License: AGPL-3.0-or-later
6
+ License-Expression: 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/.okf
9
+ Project-URL: Documentation, https://github.com/landparty/jcl-parser/blob/main/.okf/index.md
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
13
13
  Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
15
14
  Classifier: Operating System :: OS Independent
16
15
  Classifier: Programming Language :: Python :: 3
17
16
  Classifier: Programming Language :: Python :: 3.10
@@ -28,12 +27,14 @@ Requires-Dist: pytest>=7.0; extra == "dev"
28
27
  Requires-Dist: coverage[toml]>=7.0; extra == "dev"
29
28
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
30
29
  Requires-Dist: pytest-benchmark>=4.0; extra == "dev"
31
- Requires-Dist: ruff>=0.1; extra == "dev"
30
+ Requires-Dist: ruff==0.17.3; extra == "dev"
32
31
  Requires-Dist: mypy>=1.0; extra == "dev"
32
+ Requires-Dist: jsonschema>=4.18; extra == "dev"
33
33
  Requires-Dist: tomli>=1.1.0; python_version < "3.11" and extra == "dev"
34
34
  Provides-Extra: test
35
35
  Requires-Dist: pytest>=7.0; extra == "test"
36
36
  Requires-Dist: pytest-cov>=4.0; extra == "test"
37
+ Requires-Dist: jsonschema>=4.18; extra == "test"
37
38
  Requires-Dist: tomli>=1.1.0; python_version < "3.11" and extra == "test"
38
39
  Provides-Extra: profiling
39
40
  Requires-Dist: psutil>=5.9; extra == "profiling"
@@ -50,7 +51,8 @@ Python library for parsing IBM z/OS JCL (Job Control Language) into structured `
50
51
  ## Install
51
52
 
52
53
  ```bash
53
- pip install -e ".[dev]"
54
+ pip install jcl-parser # from PyPI
55
+ pip install -e ".[dev]" # from a checkout, with the dev tools
54
56
  ```
55
57
 
56
58
  Requires Python 3.10+. No runtime dependencies.
@@ -58,11 +60,20 @@ Requires Python 3.10+. No runtime dependencies.
58
60
  ## Quick Start
59
61
 
60
62
  ```python
63
+ from pathlib import Path
64
+
61
65
  import jclparser
62
66
 
63
- job = jclparser.parse_jcl(source)
67
+ job = jclparser.parse_jcl(Path("PAYROLL.jcl")) # a str is always JCL text, never a path
64
68
  print(job.name)
65
- print(job.steps[0].program)
69
+ for step in jclparser.iter_execution_steps(job.steps): # also walks IF/THEN/ELSE blocks
70
+ print(step.name, step.program or step.procedure)
71
+
72
+ exploded = jclparser.explode_job(job) # PROC expansion is a separate call
73
+ errors = jclparser.validate_jcl(
74
+ Path("PAYROLL.jcl"), level="structure"
75
+ ) # list[ParseError]
76
+ print(job.to_json(indent=2)) # schema: jclparser.JOB_JSON_SCHEMA
66
77
  ```
67
78
 
68
79
  CLI:
@@ -71,7 +82,52 @@ CLI:
71
82
  jcl-parser FILE steps # list all steps
72
83
  jcl-parser FILE --validate # validate JCL
73
84
  jcl-parser FILE lint # run lint rules
74
- jcl-parser FILE --json # full parse result as JSON
85
+ jcl-parser FILE --json # full parse result as JSON (job.to_dict())
86
+ jcl-parser --json-schema # JSON Schema of that document
87
+ jcl-parser DIR # cross-job dataset lineage of a directory of jobs
88
+ jcl-parser DIR lineage mermaid # ... as a Mermaid graph (or `dot`)
89
+ jcl-parser DIR lint --sarif # lint every job of a directory, as SARIF
90
+ jcl-lint jobs/ --fail-on warning --format sarif -o jcl.sarif # CI / pre-commit
91
+ ```
92
+
93
+ ### Lint in CI
94
+
95
+ Configure the rules in `pyproject.toml`, and switch one off for one statement
96
+ with a comment:
97
+
98
+ ```toml
99
+ [tool.jcl-parser.lint]
100
+ ignore = ["JCL-LINT-002"]
101
+ fail-on = "warning"
102
+ [tool.jcl-parser.lint.severity]
103
+ "JCL-LINT-004" = "warning"
104
+ ```
105
+
106
+ ```jcl
107
+ //* jcl-lint: disable=JCL-LINT-001
108
+ //MASTER DD DSN=PROD.MASTER.FILE,DISP=SHR
109
+ ```
110
+
111
+ pre-commit (`.pre-commit-config.yaml`):
112
+
113
+ ```yaml
114
+ - repo: https://github.com/landparty/jcl-parser
115
+ rev: v2.2.0 # first release with the hook
116
+ hooks:
117
+ - id: jcl-lint
118
+ ```
119
+
120
+ GitHub Actions (findings appear in code scanning):
121
+
122
+ ```yaml
123
+ permissions:
124
+ security-events: write
125
+ steps:
126
+ - uses: actions/checkout@v4
127
+ - uses: landparty/jcl-parser@v2.2.0 # first release with the action
128
+ with:
129
+ paths: jcl
130
+ glob: "**/*.jcl"
75
131
  ```
76
132
 
77
133
  ## Commands
@@ -83,7 +139,7 @@ pytest -m unit
83
139
 
84
140
  # Lint / format / type-check
85
141
  ruff check . && ruff format .
86
- mypy jclparser/
142
+ python scripts/mypy_ratchet.py # mypy strict against the known-error baseline, as CI runs it
87
143
 
88
144
  # Vendor sync (after changes to jclparser/) — rm first: plain `cp -r` nests
89
145
  # into dest/jclparser/jclparser/ and leaves stale files when dest exists
@@ -92,7 +148,9 @@ rm -rf ../backend/_vendor/jcl-parser/jclparser && cp -r jclparser/ ../backend/_v
92
148
 
93
149
  ## Documentation
94
150
 
95
- Full reference: [`.okf/index.md`](.okf/index.md)
151
+ Full reference: [`.okf/index.md`](.okf/index.md). Contributors: start with [`CLAUDE.md`](CLAUDE.md)
152
+ (commands, architecture, invariants) and [`.okf/development/invariants.md`](.okf/development/invariants.md).
153
+ Changes are listed in [`CHANGELOG.md`](CHANGELOG.md) (releases) and [`.okf/log.md`](.okf/log.md).
96
154
 
97
155
  ## License
98
156
 
@@ -0,0 +1,115 @@
1
+ # jcl-parser
2
+
3
+ Python library for parsing IBM z/OS JCL (Job Control Language) into structured `Job`, `Step`, and `DD` objects. Supports PROC expansion, validation, lint rules, SQL detection, and dataset lifecycle analysis.
4
+
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
6
+ [![Coverage](https://img.shields.io/badge/coverage-90%25%2B%20enforced-green.svg)](#commands)
7
+ [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE)
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install jcl-parser # from PyPI
13
+ pip install -e ".[dev]" # from a checkout, with the dev tools
14
+ ```
15
+
16
+ Requires Python 3.10+. No runtime dependencies.
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ from pathlib import Path
22
+
23
+ import jclparser
24
+
25
+ job = jclparser.parse_jcl(Path("PAYROLL.jcl")) # a str is always JCL text, never a path
26
+ print(job.name)
27
+ for step in jclparser.iter_execution_steps(job.steps): # also walks IF/THEN/ELSE blocks
28
+ print(step.name, step.program or step.procedure)
29
+
30
+ exploded = jclparser.explode_job(job) # PROC expansion is a separate call
31
+ errors = jclparser.validate_jcl(
32
+ Path("PAYROLL.jcl"), level="structure"
33
+ ) # list[ParseError]
34
+ print(job.to_json(indent=2)) # schema: jclparser.JOB_JSON_SCHEMA
35
+ ```
36
+
37
+ CLI:
38
+
39
+ ```bash
40
+ jcl-parser FILE steps # list all steps
41
+ jcl-parser FILE --validate # validate JCL
42
+ jcl-parser FILE lint # run lint rules
43
+ jcl-parser FILE --json # full parse result as JSON (job.to_dict())
44
+ jcl-parser --json-schema # JSON Schema of that document
45
+ jcl-parser DIR # cross-job dataset lineage of a directory of jobs
46
+ jcl-parser DIR lineage mermaid # ... as a Mermaid graph (or `dot`)
47
+ jcl-parser DIR lint --sarif # lint every job of a directory, as SARIF
48
+ jcl-lint jobs/ --fail-on warning --format sarif -o jcl.sarif # CI / pre-commit
49
+ ```
50
+
51
+ ### Lint in CI
52
+
53
+ Configure the rules in `pyproject.toml`, and switch one off for one statement
54
+ with a comment:
55
+
56
+ ```toml
57
+ [tool.jcl-parser.lint]
58
+ ignore = ["JCL-LINT-002"]
59
+ fail-on = "warning"
60
+ [tool.jcl-parser.lint.severity]
61
+ "JCL-LINT-004" = "warning"
62
+ ```
63
+
64
+ ```jcl
65
+ //* jcl-lint: disable=JCL-LINT-001
66
+ //MASTER DD DSN=PROD.MASTER.FILE,DISP=SHR
67
+ ```
68
+
69
+ pre-commit (`.pre-commit-config.yaml`):
70
+
71
+ ```yaml
72
+ - repo: https://github.com/landparty/jcl-parser
73
+ rev: v2.2.0 # first release with the hook
74
+ hooks:
75
+ - id: jcl-lint
76
+ ```
77
+
78
+ GitHub Actions (findings appear in code scanning):
79
+
80
+ ```yaml
81
+ permissions:
82
+ security-events: write
83
+ steps:
84
+ - uses: actions/checkout@v4
85
+ - uses: landparty/jcl-parser@v2.2.0 # first release with the action
86
+ with:
87
+ paths: jcl
88
+ glob: "**/*.jcl"
89
+ ```
90
+
91
+ ## Commands
92
+
93
+ ```bash
94
+ # Tests
95
+ pytest
96
+ pytest -m unit
97
+
98
+ # Lint / format / type-check
99
+ ruff check . && ruff format .
100
+ python scripts/mypy_ratchet.py # mypy strict against the known-error baseline, as CI runs it
101
+
102
+ # Vendor sync (after changes to jclparser/) — rm first: plain `cp -r` nests
103
+ # into dest/jclparser/jclparser/ and leaves stale files when dest exists
104
+ rm -rf ../backend/_vendor/jcl-parser/jclparser && cp -r jclparser/ ../backend/_vendor/jcl-parser/jclparser
105
+ ```
106
+
107
+ ## Documentation
108
+
109
+ Full reference: [`.okf/index.md`](.okf/index.md). Contributors: start with [`CLAUDE.md`](CLAUDE.md)
110
+ (commands, architecture, invariants) and [`.okf/development/invariants.md`](.okf/development/invariants.md).
111
+ Changes are listed in [`CHANGELOG.md`](CHANGELOG.md) (releases) and [`.okf/log.md`](.okf/log.md).
112
+
113
+ ## License
114
+
115
+ GNU Affero General Public License v3.0 or later — Copyright © 2026 Samuel Dion. See [LICENSE](LICENSE).
@@ -1,17 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
5
  Author-email: Samuel Dion <sam94dion@gmail.com>
6
- License: AGPL-3.0-or-later
6
+ License-Expression: 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/.okf
9
+ Project-URL: Documentation, https://github.com/landparty/jcl-parser/blob/main/.okf/index.md
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
13
13
  Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
15
14
  Classifier: Operating System :: OS Independent
16
15
  Classifier: Programming Language :: Python :: 3
17
16
  Classifier: Programming Language :: Python :: 3.10
@@ -28,12 +27,14 @@ Requires-Dist: pytest>=7.0; extra == "dev"
28
27
  Requires-Dist: coverage[toml]>=7.0; extra == "dev"
29
28
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
30
29
  Requires-Dist: pytest-benchmark>=4.0; extra == "dev"
31
- Requires-Dist: ruff>=0.1; extra == "dev"
30
+ Requires-Dist: ruff==0.17.3; extra == "dev"
32
31
  Requires-Dist: mypy>=1.0; extra == "dev"
32
+ Requires-Dist: jsonschema>=4.18; extra == "dev"
33
33
  Requires-Dist: tomli>=1.1.0; python_version < "3.11" and extra == "dev"
34
34
  Provides-Extra: test
35
35
  Requires-Dist: pytest>=7.0; extra == "test"
36
36
  Requires-Dist: pytest-cov>=4.0; extra == "test"
37
+ Requires-Dist: jsonschema>=4.18; extra == "test"
37
38
  Requires-Dist: tomli>=1.1.0; python_version < "3.11" and extra == "test"
38
39
  Provides-Extra: profiling
39
40
  Requires-Dist: psutil>=5.9; extra == "profiling"
@@ -50,7 +51,8 @@ Python library for parsing IBM z/OS JCL (Job Control Language) into structured `
50
51
  ## Install
51
52
 
52
53
  ```bash
53
- pip install -e ".[dev]"
54
+ pip install jcl-parser # from PyPI
55
+ pip install -e ".[dev]" # from a checkout, with the dev tools
54
56
  ```
55
57
 
56
58
  Requires Python 3.10+. No runtime dependencies.
@@ -58,11 +60,20 @@ Requires Python 3.10+. No runtime dependencies.
58
60
  ## Quick Start
59
61
 
60
62
  ```python
63
+ from pathlib import Path
64
+
61
65
  import jclparser
62
66
 
63
- job = jclparser.parse_jcl(source)
67
+ job = jclparser.parse_jcl(Path("PAYROLL.jcl")) # a str is always JCL text, never a path
64
68
  print(job.name)
65
- print(job.steps[0].program)
69
+ for step in jclparser.iter_execution_steps(job.steps): # also walks IF/THEN/ELSE blocks
70
+ print(step.name, step.program or step.procedure)
71
+
72
+ exploded = jclparser.explode_job(job) # PROC expansion is a separate call
73
+ errors = jclparser.validate_jcl(
74
+ Path("PAYROLL.jcl"), level="structure"
75
+ ) # list[ParseError]
76
+ print(job.to_json(indent=2)) # schema: jclparser.JOB_JSON_SCHEMA
66
77
  ```
67
78
 
68
79
  CLI:
@@ -71,7 +82,52 @@ CLI:
71
82
  jcl-parser FILE steps # list all steps
72
83
  jcl-parser FILE --validate # validate JCL
73
84
  jcl-parser FILE lint # run lint rules
74
- jcl-parser FILE --json # full parse result as JSON
85
+ jcl-parser FILE --json # full parse result as JSON (job.to_dict())
86
+ jcl-parser --json-schema # JSON Schema of that document
87
+ jcl-parser DIR # cross-job dataset lineage of a directory of jobs
88
+ jcl-parser DIR lineage mermaid # ... as a Mermaid graph (or `dot`)
89
+ jcl-parser DIR lint --sarif # lint every job of a directory, as SARIF
90
+ jcl-lint jobs/ --fail-on warning --format sarif -o jcl.sarif # CI / pre-commit
91
+ ```
92
+
93
+ ### Lint in CI
94
+
95
+ Configure the rules in `pyproject.toml`, and switch one off for one statement
96
+ with a comment:
97
+
98
+ ```toml
99
+ [tool.jcl-parser.lint]
100
+ ignore = ["JCL-LINT-002"]
101
+ fail-on = "warning"
102
+ [tool.jcl-parser.lint.severity]
103
+ "JCL-LINT-004" = "warning"
104
+ ```
105
+
106
+ ```jcl
107
+ //* jcl-lint: disable=JCL-LINT-001
108
+ //MASTER DD DSN=PROD.MASTER.FILE,DISP=SHR
109
+ ```
110
+
111
+ pre-commit (`.pre-commit-config.yaml`):
112
+
113
+ ```yaml
114
+ - repo: https://github.com/landparty/jcl-parser
115
+ rev: v2.2.0 # first release with the hook
116
+ hooks:
117
+ - id: jcl-lint
118
+ ```
119
+
120
+ GitHub Actions (findings appear in code scanning):
121
+
122
+ ```yaml
123
+ permissions:
124
+ security-events: write
125
+ steps:
126
+ - uses: actions/checkout@v4
127
+ - uses: landparty/jcl-parser@v2.2.0 # first release with the action
128
+ with:
129
+ paths: jcl
130
+ glob: "**/*.jcl"
75
131
  ```
76
132
 
77
133
  ## Commands
@@ -83,7 +139,7 @@ pytest -m unit
83
139
 
84
140
  # Lint / format / type-check
85
141
  ruff check . && ruff format .
86
- mypy jclparser/
142
+ python scripts/mypy_ratchet.py # mypy strict against the known-error baseline, as CI runs it
87
143
 
88
144
  # Vendor sync (after changes to jclparser/) — rm first: plain `cp -r` nests
89
145
  # into dest/jclparser/jclparser/ and leaves stale files when dest exists
@@ -92,7 +148,9 @@ rm -rf ../backend/_vendor/jcl-parser/jclparser && cp -r jclparser/ ../backend/_v
92
148
 
93
149
  ## Documentation
94
150
 
95
- Full reference: [`.okf/index.md`](.okf/index.md)
151
+ Full reference: [`.okf/index.md`](.okf/index.md). Contributors: start with [`CLAUDE.md`](CLAUDE.md)
152
+ (commands, architecture, invariants) and [`.okf/development/invariants.md`](.okf/development/invariants.md).
153
+ Changes are listed in [`CHANGELOG.md`](CHANGELOG.md) (releases) and [`.okf/log.md`](.okf/log.md).
96
154
 
97
155
  ## License
98
156
 
@@ -16,6 +16,7 @@ jclparser/_post_parse.py
16
16
  jclparser/_source_scanner.py
17
17
  jclparser/_statement_parser.py
18
18
  jclparser/builtin_procs.py
19
+ jclparser/checks.py
19
20
  jclparser/cli.py
20
21
  jclparser/condition_parser.py
21
22
  jclparser/continuation_handler.py
@@ -25,6 +26,7 @@ jclparser/explosion.py
25
26
  jclparser/include_loader.py
26
27
  jclparser/jcl_file_type.py
27
28
  jclparser/jes_statements.py
29
+ jclparser/lineage.py
28
30
  jclparser/metrics.py
29
31
  jclparser/model.py
30
32
  jclparser/parameter_ast.py
@@ -34,6 +36,7 @@ jclparser/proc_loader.py
34
36
  jclparser/profiling.py
35
37
  jclparser/py.typed
36
38
  jclparser/rc_analysis.py
39
+ jclparser/serialization.py
37
40
  jclparser/sql_detector.py
38
41
  jclparser/system_symbols.py
39
42
  jclparser/utils.py
@@ -43,6 +46,7 @@ jclparser/lint/base.py
43
46
  jclparser/lint/config.py
44
47
  jclparser/lint/rules.py
45
48
  jclparser/lint/runner.py
49
+ jclparser/lint/suppress.py
46
50
  jclparser/plugins/__init__.py
47
51
  jclparser/plugins/base.py
48
52
  jclparser/program_profiles/__init__.py
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
+ jcl-lint = jclparser.checks:lint_main
2
3
  jcl-parser = jclparser.cli:main
@@ -4,8 +4,9 @@ pytest>=7.0
4
4
  coverage[toml]>=7.0
5
5
  pytest-cov>=4.0
6
6
  pytest-benchmark>=4.0
7
- ruff>=0.1
7
+ ruff==0.17.3
8
8
  mypy>=1.0
9
+ jsonschema>=4.18
9
10
 
10
11
  [dev:python_version < "3.11"]
11
12
  tomli>=1.1.0
@@ -16,6 +17,7 @@ psutil>=5.9
16
17
  [test]
17
18
  pytest>=7.0
18
19
  pytest-cov>=4.0
20
+ jsonschema>=4.18
19
21
 
20
22
  [test:python_version < "3.11"]
21
23
  tomli>=1.1.0
@@ -20,7 +20,7 @@ application, for example::
20
20
  import logging as _logging
21
21
 
22
22
  # Package metadata
23
- __version__ = "2.1.0" # x-release-please-version
23
+ __version__ = "2.2.0" # x-release-please-version
24
24
  __author__ = "JCL Parser Team"
25
25
  __description__ = "Parse IBM JCL scripts into Python objects"
26
26
 
@@ -90,6 +90,15 @@ from .jes_statements import (
90
90
  XmitStatement,
91
91
  parse_jes_control,
92
92
  )
93
+ from .lineage import (
94
+ DatasetLineage,
95
+ DatasetUse,
96
+ JobDependency,
97
+ JobStream,
98
+ LineageReport,
99
+ build_lineage,
100
+ load_job_stream,
101
+ )
93
102
  from .lint import (
94
103
  LintConfig,
95
104
  LintRule,
@@ -143,6 +152,12 @@ from .rc_analysis import (
143
152
  FeasibilityReport,
144
153
  compute_feasible_paths,
145
154
  )
155
+ from .serialization import (
156
+ JOB_JSON_SCHEMA,
157
+ SCHEMA_VERSION,
158
+ job_to_dict,
159
+ job_to_json,
160
+ )
146
161
  from .sql_detector import (
147
162
  DB2_PROGRAMS,
148
163
  SQLDetectionResult,
@@ -167,9 +182,6 @@ from .validators import (
167
182
  # default unless the consuming application configures handlers.
168
183
  _logging.getLogger(__name__).addHandler(_logging.NullHandler())
169
184
 
170
- # Initialize global plugin registry
171
- _registry = get_plugin_registry()
172
-
173
185
  # Public API - what gets imported with "from jclparser import *"
174
186
  __all__ = [ # noqa: RUF022 — grouped by API tier, not alphabetically
175
187
  # ── Tier 1: Stable public API ─────────────────────────────────────────────
@@ -246,6 +258,19 @@ __all__ = [ # noqa: RUF022 — grouped by API tier, not alphabetically
246
258
  "LineOrigin",
247
259
  "resolve_includes",
248
260
  "resolve_includes_with_origins",
261
+ # ── Tier 2: JSON export ───────────────────────────────────────────────────
262
+ "job_to_dict",
263
+ "job_to_json",
264
+ "JOB_JSON_SCHEMA",
265
+ "SCHEMA_VERSION",
266
+ # ── Tier 2: Cross-job dataset lineage ─────────────────────────────────────
267
+ "load_job_stream",
268
+ "build_lineage",
269
+ "JobStream",
270
+ "LineageReport",
271
+ "DatasetLineage",
272
+ "DatasetUse",
273
+ "JobDependency",
249
274
  # ── Tier 2: Dataset lifecycle analysis ───────────────────────────────────
250
275
  "DatasetEvent",
251
276
  "analyze_dataset_lifecycle",
@@ -90,6 +90,9 @@ class IncludeLineMap:
90
90
  (step_name, ref, self.translate(line))
91
91
  for step_name, ref, line in job.unresolved_output_refs
92
92
  ]
93
+ job.conditional_errors[:] = [
94
+ (self.translate(line), message) for line, message in job.conditional_errors
95
+ ]
93
96
 
94
97
  def _relabel_object(self, obj: Any, seen: set[int]) -> None:
95
98
  """Translate ``line_number`` on *obj* and every model object under it."""
@@ -68,6 +68,8 @@ logger = logging.getLogger(__name__)
68
68
 
69
69
  #: A record that looks like a JCL ``EXEC`` statement (``//name EXEC ...``).
70
70
  _RE_EXEC_RECORD = re.compile(r"^//\S+\s+EXEC(\s|$)")
71
+ #: A JOB statement record (named; ``//*`` comments never reach the check).
72
+ _RE_JOB_RECORD = re.compile(r"^//\S+\s+JOB(\s|$)")
71
73
 
72
74
  #: ``/*`` statements JES3 itself processes (RJP workstation sign-on/off); every
73
75
  #: other ``/*`` control statement is JES2-only.
@@ -203,6 +205,14 @@ class JobBuilder:
203
205
  #: The open ``CNTL`` block, whose program control records are collected
204
206
  #: verbatim until its ``ENDCNTL``.
205
207
  self.current_cntl: Statement | None = None
208
+ #: Line of the null statement (``//``) that ended the job, or None.
209
+ self.null_line: int | None = None
210
+ #: Line numbers of the records after it, which are not part of the job.
211
+ self.after_null_lines: list[int] = []
212
+ #: Misplaced ``IF``/``THEN``/``ELSE``/``ENDIF`` statements as
213
+ #: ``(line, message)``; finish() copies them to
214
+ #: :attr:`Job.conditional_errors`.
215
+ self.conditional_errors: list[tuple[int, str]] = []
206
216
 
207
217
  # ------------------------------------------------------------------ #
208
218
  # Line-level handling (before statement parsing)
@@ -465,6 +475,15 @@ class JobBuilder:
465
475
  f"Line {cond.line_number}: IF block in PROC {procedure.name} "
466
476
  f"has no matching ENDIF; closed at PEND (line {line_num})"
467
477
  )
478
+ self.conditional_errors.append(
479
+ (
480
+ cond.line_number,
481
+ (
482
+ f"IF statement in PROC {procedure.name} has no "
483
+ "matching ENDIF before PEND"
484
+ ),
485
+ )
486
+ )
468
487
  self._warn_proc_orphaned_dds(procedure, line_num)
469
488
  self.proc_orphaned_dds = []
470
489
  self.proc_cond_depth = 0
@@ -660,12 +679,31 @@ class JobBuilder:
660
679
  def _switch_branch(self, branch: str, line_num: int) -> None:
661
680
  """Handle ``THEN`` / ``ELSE``: make *branch* the open IF's active one."""
662
681
  open_if = self._innermost_if()
663
- if open_if is not None:
664
- self.conditional_stack[-1] = (open_if[0], branch)
665
- else:
682
+ if open_if is None:
666
683
  self._warnings_sink().append(
667
684
  f"Line {line_num}: {branch} with no matching IF; ignored"
668
685
  )
686
+ self.conditional_errors.append(
687
+ (line_num, f"{branch} statement with no matching IF")
688
+ )
689
+ return
690
+ if branch == "ELSE" and open_if[1] == "ELSE":
691
+ # An IF has at most one ELSE clause; the second one's steps are
692
+ # still read into the ELSE branch.
693
+ self._warnings_sink().append(
694
+ f"Line {line_num}: second ELSE for the IF on line "
695
+ f"{open_if[0].line_number}"
696
+ )
697
+ self.conditional_errors.append(
698
+ (
699
+ line_num,
700
+ (
701
+ "ELSE statement repeats the ELSE of the IF on line "
702
+ f"{open_if[0].line_number}"
703
+ ),
704
+ )
705
+ )
706
+ self.conditional_stack[-1] = (open_if[0], branch)
669
707
 
670
708
  def _close_if(self, line_num: int) -> None:
671
709
  """Handle ``ENDIF``: leave the innermost open IF block."""
@@ -675,6 +713,9 @@ class JobBuilder:
675
713
  self._warnings_sink().append(
676
714
  f"Line {line_num}: ENDIF with no matching IF; ignored"
677
715
  )
716
+ self.conditional_errors.append(
717
+ (line_num, "ENDIF statement with no matching IF")
718
+ )
678
719
 
679
720
  def _on_jes(self, stmt: Statement, line_num: int) -> None:
680
721
  raw_params = stmt.parameters.get("_raw", "")
@@ -725,8 +766,23 @@ class JobBuilder:
725
766
  sink.append(output)
726
767
 
727
768
  def _on_null(self, stmt: Statement, line_num: int) -> None:
728
- # NULL statement (//) - just ignore, used as delimiter
729
- pass
769
+ # The null statement (//) marks the end of the job on z/OS: what
770
+ # follows, up to the next JOB statement, is not part of it (#124).
771
+ # Before the JOB statement it delimits nothing and is ignored.
772
+ if self.job is not None and self.null_line is None:
773
+ self.null_line = line_num
774
+
775
+ def skip_after_null(self, line: str, line_num: int) -> bool:
776
+ """True if *line* follows the null statement that ended the job.
777
+
778
+ Such a record is not part of the job, so the caller skips it; it is
779
+ counted for the warning :meth:`finish` adds. A JOB statement is not
780
+ skipped: a second job is still the "Multiple JOB statements" error.
781
+ """
782
+ if self.null_line is None or _RE_JOB_RECORD.match(line):
783
+ return False
784
+ self.after_null_lines.append(line_num)
785
+ return True
730
786
 
731
787
  def _on_control(self, stmt: Statement, line_num: int) -> None:
732
788
  """Record a COMMAND / SCHEDULE / CNTL / NOTIFY statement verbatim."""
@@ -878,9 +934,20 @@ class JobBuilder:
878
934
  self._flush_pending_statements(job)
879
935
  self._warn_orphaned_dds(job)
880
936
  self._warn_unclosed_ifs(job)
937
+ self._warn_after_null(job)
881
938
 
882
939
  return job
883
940
 
941
+ def _warn_after_null(self, job: Job) -> None:
942
+ if self.null_line is not None and self.after_null_lines:
943
+ job.warnings.append(
944
+ f"Line {self.null_line}: null statement (//) ends job "
945
+ f"{job.name or '(unnamed)'}; {len(self.after_null_lines)} "
946
+ f"record(s) after it (lines {self.after_null_lines[0]}-"
947
+ f"{self.after_null_lines[-1]}) are not part of the job and "
948
+ "were ignored"
949
+ )
950
+
884
951
  def _job_or_procedure_member(self) -> Job:
885
952
  """The parsed Job, or a synthetic one for a JOB-less PROC member."""
886
953
  if self.job is not None:
@@ -969,3 +1036,7 @@ class JobBuilder:
969
1036
  job.warnings.append(
970
1037
  f"Line {_cond.line_number}: IF block has no matching ENDIF"
971
1038
  )
1039
+ self.conditional_errors.append(
1040
+ (_cond.line_number, "IF statement has no matching ENDIF")
1041
+ )
1042
+ job.conditional_errors.extend(sorted(self.conditional_errors))
@@ -358,15 +358,6 @@ def split_comment_field(
358
358
  return (line[:start] + operands[:cut]).rstrip(), operands[cut:].strip()
359
359
 
360
360
 
361
- def strip_comment_field(line: str, group_operation: str = "") -> str:
362
- """Return *line* without its trailing comment field.
363
-
364
- Thin wrapper over :func:`split_comment_field` for callers that do not need
365
- the comment text.
366
- """
367
- return split_comment_field(line, group_operation)[0]
368
-
369
-
370
361
  def _find_statement_plugin(line: str, config: ParserConfig) -> StatementPlugin | None:
371
362
  """Return the first plugin *config* enables that can parse *line*, if any."""
372
363
  for plugin in get_plugin_registry().get_statement_plugins():