cdisc-trace-agent 1.0.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.
- cdisc_trace_agent-1.0.0/LICENSE +21 -0
- cdisc_trace_agent-1.0.0/PKG-INFO +108 -0
- cdisc_trace_agent-1.0.0/README.md +86 -0
- cdisc_trace_agent-1.0.0/cdisc_trace_agent.egg-info/PKG-INFO +108 -0
- cdisc_trace_agent-1.0.0/cdisc_trace_agent.egg-info/SOURCES.txt +10 -0
- cdisc_trace_agent-1.0.0/cdisc_trace_agent.egg-info/dependency_links.txt +1 -0
- cdisc_trace_agent-1.0.0/cdisc_trace_agent.egg-info/entry_points.txt +2 -0
- cdisc_trace_agent-1.0.0/cdisc_trace_agent.egg-info/requires.txt +3 -0
- cdisc_trace_agent-1.0.0/cdisc_trace_agent.egg-info/top_level.txt +1 -0
- cdisc_trace_agent-1.0.0/pyproject.toml +35 -0
- cdisc_trace_agent-1.0.0/setup.cfg +4 -0
- cdisc_trace_agent-1.0.0/trace_agent.py +730 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Giri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cdisc-trace-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Variable-level traceability checker for CDISC SDTM/ADaM submissions
|
|
5
|
+
Author: Giri
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/girishkankipati2-pixel/trace-agent
|
|
8
|
+
Keywords: cdisc,sdtm,adam,define-xml,clinical,traceability,fda,pharma
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: lxml>=5.0
|
|
19
|
+
Requires-Dist: pyreadstat>=1.2
|
|
20
|
+
Requires-Dist: pandas>=2.0
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# trace-agent — clinical data traceability checker
|
|
24
|
+
|
|
25
|
+
[](LICENSE)
|
|
26
|
+
[](https://www.python.org/)
|
|
27
|
+
[](https://pypi.org/project/cdisc-trace-agent/)
|
|
28
|
+
|
|
29
|
+
**Free, open-source tool that finds traceability gaps in CDISC submissions before the FDA does.**
|
|
30
|
+
|
|
31
|
+
Given a study's CDISC `define.xml` (2.0/2.1) and its SAS transport (XPT)
|
|
32
|
+
datasets, `trace-agent` builds a **variable-level lineage graph**
|
|
33
|
+
(ADaM ← SDTM ← aCRF/protocol) and flags **traceability gaps** — the #1
|
|
34
|
+
source of findings in regulatory review.
|
|
35
|
+
|
|
36
|
+
🚀 **[Try the live demo in your browser](https://muse.ai/s/trace-agent-xcxz5y1xossxxxao)** — no install needed.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install cdisc-trace-agent
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or from source:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/girishkankipati2-pixel/trace-agent.git
|
|
48
|
+
cd trace-agent
|
|
49
|
+
pip install -e .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Run
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
trace-agent --study <study-folder> --out report.html
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The study folder must contain `define.xml` at its root and `*.xpt` files
|
|
59
|
+
at the root or in an `xpt/` subdirectory. A text summary is printed and a
|
|
60
|
+
self-contained HTML report (inline SVG + JS, no external dependencies) is
|
|
61
|
+
written to `--out`. The report has summary counts, a gap table, and a
|
|
62
|
+
clickable lineage graph (click a node to highlight its lineage).
|
|
63
|
+
|
|
64
|
+
## Demo
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python make_demo.py # builds demo_study/
|
|
68
|
+
python trace_agent.py --study demo_study --out demo_report.html
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`demo_study/` is a tiny synthetic study (SDTM DM + AE, ADaM ADSL) with five
|
|
72
|
+
deliberately planted gaps — see `demo_study/planted_gaps.txt`. The demo run
|
|
73
|
+
should report all of them.
|
|
74
|
+
|
|
75
|
+
## Gaps detected
|
|
76
|
+
|
|
77
|
+
| Code | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `XPT_ONLY` | Variable present in XPT but missing from define.xml |
|
|
80
|
+
| `DEFINE_ONLY` | Variable defined in define.xml but missing from XPT |
|
|
81
|
+
| `DERIVED_NO_METHOD` | ADaM variable with Origin=`Derived` but no MethodDef/description |
|
|
82
|
+
| `DANGLING_REF` | Derivation references a variable defined nowhere |
|
|
83
|
+
| `NO_SDTM_PATH` | ADaM variable with no predecessor chain reaching SDTM |
|
|
84
|
+
|
|
85
|
+
## How lineage is resolved
|
|
86
|
+
|
|
87
|
+
For each variable, predecessors are resolved from, in order:
|
|
88
|
+
|
|
89
|
+
1. The linked `def:MethodDef` (via `ItemRef/@MethodOID`, per the
|
|
90
|
+
Define-XML 2.0 spec) — description and formal-expression text are scanned
|
|
91
|
+
for `DOMAIN.VAR` references.
|
|
92
|
+
2. Inline `def:Origin` description text (same reference scanning).
|
|
93
|
+
3. `def:Origin Type="Predecessor"` — links to the same-named variable in
|
|
94
|
+
SDTM datasets.
|
|
95
|
+
|
|
96
|
+
Explicit dotted references (`DM.AGE`) are authoritative; bare tokens only
|
|
97
|
+
count when they uniquely match one known variable name.
|
|
98
|
+
|
|
99
|
+
## Limitations (v1)
|
|
100
|
+
|
|
101
|
+
- Annotated CRF / protocol documents are not parsed; CRF origins are
|
|
102
|
+
recorded but not linked to pages.
|
|
103
|
+
- `def:DocumentRef`, value-level metadata (`ValueListDef`), and ARM
|
|
104
|
+
(`arm:AnalysisResult`) are not yet followed.
|
|
105
|
+
- Dataset role (SDTM vs ADaM) uses `def:Class`, falling back to an `AD*`
|
|
106
|
+
name heuristic.
|
|
107
|
+
- Reference extraction from derivation prose is heuristic text matching,
|
|
108
|
+
not SAS parsing.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# trace-agent — clinical data traceability checker
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://www.python.org/)
|
|
5
|
+
[](https://pypi.org/project/cdisc-trace-agent/)
|
|
6
|
+
|
|
7
|
+
**Free, open-source tool that finds traceability gaps in CDISC submissions before the FDA does.**
|
|
8
|
+
|
|
9
|
+
Given a study's CDISC `define.xml` (2.0/2.1) and its SAS transport (XPT)
|
|
10
|
+
datasets, `trace-agent` builds a **variable-level lineage graph**
|
|
11
|
+
(ADaM ← SDTM ← aCRF/protocol) and flags **traceability gaps** — the #1
|
|
12
|
+
source of findings in regulatory review.
|
|
13
|
+
|
|
14
|
+
🚀 **[Try the live demo in your browser](https://muse.ai/s/trace-agent-xcxz5y1xossxxxao)** — no install needed.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install cdisc-trace-agent
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or from source:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/girishkankipati2-pixel/trace-agent.git
|
|
26
|
+
cd trace-agent
|
|
27
|
+
pip install -e .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Run
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
trace-agent --study <study-folder> --out report.html
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The study folder must contain `define.xml` at its root and `*.xpt` files
|
|
37
|
+
at the root or in an `xpt/` subdirectory. A text summary is printed and a
|
|
38
|
+
self-contained HTML report (inline SVG + JS, no external dependencies) is
|
|
39
|
+
written to `--out`. The report has summary counts, a gap table, and a
|
|
40
|
+
clickable lineage graph (click a node to highlight its lineage).
|
|
41
|
+
|
|
42
|
+
## Demo
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python make_demo.py # builds demo_study/
|
|
46
|
+
python trace_agent.py --study demo_study --out demo_report.html
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`demo_study/` is a tiny synthetic study (SDTM DM + AE, ADaM ADSL) with five
|
|
50
|
+
deliberately planted gaps — see `demo_study/planted_gaps.txt`. The demo run
|
|
51
|
+
should report all of them.
|
|
52
|
+
|
|
53
|
+
## Gaps detected
|
|
54
|
+
|
|
55
|
+
| Code | Meaning |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `XPT_ONLY` | Variable present in XPT but missing from define.xml |
|
|
58
|
+
| `DEFINE_ONLY` | Variable defined in define.xml but missing from XPT |
|
|
59
|
+
| `DERIVED_NO_METHOD` | ADaM variable with Origin=`Derived` but no MethodDef/description |
|
|
60
|
+
| `DANGLING_REF` | Derivation references a variable defined nowhere |
|
|
61
|
+
| `NO_SDTM_PATH` | ADaM variable with no predecessor chain reaching SDTM |
|
|
62
|
+
|
|
63
|
+
## How lineage is resolved
|
|
64
|
+
|
|
65
|
+
For each variable, predecessors are resolved from, in order:
|
|
66
|
+
|
|
67
|
+
1. The linked `def:MethodDef` (via `ItemRef/@MethodOID`, per the
|
|
68
|
+
Define-XML 2.0 spec) — description and formal-expression text are scanned
|
|
69
|
+
for `DOMAIN.VAR` references.
|
|
70
|
+
2. Inline `def:Origin` description text (same reference scanning).
|
|
71
|
+
3. `def:Origin Type="Predecessor"` — links to the same-named variable in
|
|
72
|
+
SDTM datasets.
|
|
73
|
+
|
|
74
|
+
Explicit dotted references (`DM.AGE`) are authoritative; bare tokens only
|
|
75
|
+
count when they uniquely match one known variable name.
|
|
76
|
+
|
|
77
|
+
## Limitations (v1)
|
|
78
|
+
|
|
79
|
+
- Annotated CRF / protocol documents are not parsed; CRF origins are
|
|
80
|
+
recorded but not linked to pages.
|
|
81
|
+
- `def:DocumentRef`, value-level metadata (`ValueListDef`), and ARM
|
|
82
|
+
(`arm:AnalysisResult`) are not yet followed.
|
|
83
|
+
- Dataset role (SDTM vs ADaM) uses `def:Class`, falling back to an `AD*`
|
|
84
|
+
name heuristic.
|
|
85
|
+
- Reference extraction from derivation prose is heuristic text matching,
|
|
86
|
+
not SAS parsing.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cdisc-trace-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Variable-level traceability checker for CDISC SDTM/ADaM submissions
|
|
5
|
+
Author: Giri
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/girishkankipati2-pixel/trace-agent
|
|
8
|
+
Keywords: cdisc,sdtm,adam,define-xml,clinical,traceability,fda,pharma
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: lxml>=5.0
|
|
19
|
+
Requires-Dist: pyreadstat>=1.2
|
|
20
|
+
Requires-Dist: pandas>=2.0
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# trace-agent — clinical data traceability checker
|
|
24
|
+
|
|
25
|
+
[](LICENSE)
|
|
26
|
+
[](https://www.python.org/)
|
|
27
|
+
[](https://pypi.org/project/cdisc-trace-agent/)
|
|
28
|
+
|
|
29
|
+
**Free, open-source tool that finds traceability gaps in CDISC submissions before the FDA does.**
|
|
30
|
+
|
|
31
|
+
Given a study's CDISC `define.xml` (2.0/2.1) and its SAS transport (XPT)
|
|
32
|
+
datasets, `trace-agent` builds a **variable-level lineage graph**
|
|
33
|
+
(ADaM ← SDTM ← aCRF/protocol) and flags **traceability gaps** — the #1
|
|
34
|
+
source of findings in regulatory review.
|
|
35
|
+
|
|
36
|
+
🚀 **[Try the live demo in your browser](https://muse.ai/s/trace-agent-xcxz5y1xossxxxao)** — no install needed.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install cdisc-trace-agent
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or from source:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/girishkankipati2-pixel/trace-agent.git
|
|
48
|
+
cd trace-agent
|
|
49
|
+
pip install -e .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Run
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
trace-agent --study <study-folder> --out report.html
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The study folder must contain `define.xml` at its root and `*.xpt` files
|
|
59
|
+
at the root or in an `xpt/` subdirectory. A text summary is printed and a
|
|
60
|
+
self-contained HTML report (inline SVG + JS, no external dependencies) is
|
|
61
|
+
written to `--out`. The report has summary counts, a gap table, and a
|
|
62
|
+
clickable lineage graph (click a node to highlight its lineage).
|
|
63
|
+
|
|
64
|
+
## Demo
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python make_demo.py # builds demo_study/
|
|
68
|
+
python trace_agent.py --study demo_study --out demo_report.html
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`demo_study/` is a tiny synthetic study (SDTM DM + AE, ADaM ADSL) with five
|
|
72
|
+
deliberately planted gaps — see `demo_study/planted_gaps.txt`. The demo run
|
|
73
|
+
should report all of them.
|
|
74
|
+
|
|
75
|
+
## Gaps detected
|
|
76
|
+
|
|
77
|
+
| Code | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `XPT_ONLY` | Variable present in XPT but missing from define.xml |
|
|
80
|
+
| `DEFINE_ONLY` | Variable defined in define.xml but missing from XPT |
|
|
81
|
+
| `DERIVED_NO_METHOD` | ADaM variable with Origin=`Derived` but no MethodDef/description |
|
|
82
|
+
| `DANGLING_REF` | Derivation references a variable defined nowhere |
|
|
83
|
+
| `NO_SDTM_PATH` | ADaM variable with no predecessor chain reaching SDTM |
|
|
84
|
+
|
|
85
|
+
## How lineage is resolved
|
|
86
|
+
|
|
87
|
+
For each variable, predecessors are resolved from, in order:
|
|
88
|
+
|
|
89
|
+
1. The linked `def:MethodDef` (via `ItemRef/@MethodOID`, per the
|
|
90
|
+
Define-XML 2.0 spec) — description and formal-expression text are scanned
|
|
91
|
+
for `DOMAIN.VAR` references.
|
|
92
|
+
2. Inline `def:Origin` description text (same reference scanning).
|
|
93
|
+
3. `def:Origin Type="Predecessor"` — links to the same-named variable in
|
|
94
|
+
SDTM datasets.
|
|
95
|
+
|
|
96
|
+
Explicit dotted references (`DM.AGE`) are authoritative; bare tokens only
|
|
97
|
+
count when they uniquely match one known variable name.
|
|
98
|
+
|
|
99
|
+
## Limitations (v1)
|
|
100
|
+
|
|
101
|
+
- Annotated CRF / protocol documents are not parsed; CRF origins are
|
|
102
|
+
recorded but not linked to pages.
|
|
103
|
+
- `def:DocumentRef`, value-level metadata (`ValueListDef`), and ARM
|
|
104
|
+
(`arm:AnalysisResult`) are not yet followed.
|
|
105
|
+
- Dataset role (SDTM vs ADaM) uses `def:Class`, falling back to an `AD*`
|
|
106
|
+
name heuristic.
|
|
107
|
+
- Reference extraction from derivation prose is heuristic text matching,
|
|
108
|
+
not SAS parsing.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
trace_agent.py
|
|
5
|
+
cdisc_trace_agent.egg-info/PKG-INFO
|
|
6
|
+
cdisc_trace_agent.egg-info/SOURCES.txt
|
|
7
|
+
cdisc_trace_agent.egg-info/dependency_links.txt
|
|
8
|
+
cdisc_trace_agent.egg-info/entry_points.txt
|
|
9
|
+
cdisc_trace_agent.egg-info/requires.txt
|
|
10
|
+
cdisc_trace_agent.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trace_agent
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cdisc-trace-agent"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Variable-level traceability checker for CDISC SDTM/ADaM submissions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Giri" }]
|
|
13
|
+
keywords = ["cdisc", "sdtm", "adam", "define-xml", "clinical", "traceability", "fda", "pharma"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Healthcare Industry",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"lxml>=5.0",
|
|
24
|
+
"pyreadstat>=1.2",
|
|
25
|
+
"pandas>=2.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
trace-agent = "trace_agent:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/girishkankipati2-pixel/trace-agent"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
py-modules = ["trace_agent"]
|
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
trace.py -- Clinical data traceability agent (v1 prototype).
|
|
4
|
+
|
|
5
|
+
Given a study folder containing a CDISC define.xml (2.0/2.1) and SAS
|
|
6
|
+
transport (XPT) datasets, this tool:
|
|
7
|
+
|
|
8
|
+
1. Parses define.xml for datasets (ItemGroupDef), variables (ItemDef,
|
|
9
|
+
incl. def:Origin), derivations (def:MethodDef) and the ItemRef ->
|
|
10
|
+
MethodDef linkage (ItemRef/@MethodOID, per the Define-XML 2.0 spec).
|
|
11
|
+
2. Reads the XPT datasets and compares them against define.xml.
|
|
12
|
+
3. Builds a variable-level lineage graph (ADaM <- SDTM <- aCRF/protocol).
|
|
13
|
+
4. Flags traceability gaps.
|
|
14
|
+
5. Writes a single self-contained HTML report (inline SVG + JS).
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python trace.py --study <folder> --out report.html
|
|
18
|
+
|
|
19
|
+
Study folder layout expected:
|
|
20
|
+
<folder>/define.xml (CDISC Define-XML 2.0 or 2.1)
|
|
21
|
+
<folder>/*.xpt (SAS transport files, e.g. dm.xpt)
|
|
22
|
+
<folder>/xpt/*.xpt (also searched)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import html as html_lib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
from collections import deque
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from lxml import etree
|
|
35
|
+
except ImportError:
|
|
36
|
+
sys.exit("ERROR: lxml is required. Run: pip install -r requirements.txt")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
import pyreadstat
|
|
40
|
+
except ImportError:
|
|
41
|
+
sys.exit("ERROR: pyreadstat is required. Run: pip install -r requirements.txt")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# XML helpers (namespace-agnostic so both define 2.0 and 2.1 parse the same)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
DEF_NS_URIS = (
|
|
49
|
+
"http://www.cdisc.org/ns/def/v2.0",
|
|
50
|
+
"http://www.cdisc.org/ns/def/v2.1",
|
|
51
|
+
)
|
|
52
|
+
XLINK_HREF = "{http://www.w3.org/1999/xlink}href"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def local(tag):
|
|
56
|
+
"""Strip any namespace, returning the local element name."""
|
|
57
|
+
if not isinstance(tag, str): # comments/PIs have non-string tags
|
|
58
|
+
return ""
|
|
59
|
+
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def child(el, name):
|
|
63
|
+
for c in el:
|
|
64
|
+
if local(c.tag) == name:
|
|
65
|
+
return c
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def children(el, name):
|
|
70
|
+
return [c for c in el if local(c.tag) == name]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def def_attr(el, name):
|
|
74
|
+
"""Read a define-extension attribute (def:Name) regardless of 2.0/2.1."""
|
|
75
|
+
for uri in DEF_NS_URIS:
|
|
76
|
+
v = el.get("{%s}%s" % (uri, name))
|
|
77
|
+
if v is not None:
|
|
78
|
+
return v
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def desc_text(el):
|
|
83
|
+
"""Text of <Description><TranslatedText>...</TranslatedText></Description>."""
|
|
84
|
+
if el is None:
|
|
85
|
+
return ""
|
|
86
|
+
d = child(el, "Description")
|
|
87
|
+
if d is None:
|
|
88
|
+
return ""
|
|
89
|
+
t = child(d, "TranslatedText")
|
|
90
|
+
target = t if t is not None else d
|
|
91
|
+
return "".join(target.itertext()).strip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def formal_expression_text(method_el):
|
|
95
|
+
parts = []
|
|
96
|
+
for fe in method_el.iter():
|
|
97
|
+
if local(fe.tag) == "FormalExpression":
|
|
98
|
+
ctx = fe.get("Context") or ""
|
|
99
|
+
body = "".join(fe.itertext()).strip()
|
|
100
|
+
parts.append(("[%s] %s" % (ctx, body)) if ctx else body)
|
|
101
|
+
return " ".join(parts).strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# define.xml parsing
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def parse_define(path):
|
|
109
|
+
"""Parse define.xml -> (datasets, variables, methods).
|
|
110
|
+
|
|
111
|
+
datasets: {DSNAME: {name, label, domain, role, xpt, var_order:[VARNAME]}}
|
|
112
|
+
variables: {"DS.VAR": {dataset, name, label, dtype, length, origin_type,
|
|
113
|
+
origin_desc, method_oid}}
|
|
114
|
+
methods: {OID: {oid, name, type, description, formal}}
|
|
115
|
+
role is 'ADAM' or 'SDTM' (def:Class wins; otherwise AD* name heuristic).
|
|
116
|
+
"""
|
|
117
|
+
tree = etree.parse(path)
|
|
118
|
+
root = tree.getroot()
|
|
119
|
+
|
|
120
|
+
mdv = None
|
|
121
|
+
for el in root.iter():
|
|
122
|
+
if local(el.tag) == "MetaDataVersion":
|
|
123
|
+
mdv = el
|
|
124
|
+
break
|
|
125
|
+
if mdv is None:
|
|
126
|
+
raise ValueError("No MetaDataVersion element found in %s" % path)
|
|
127
|
+
|
|
128
|
+
# def:leaf ID -> href (maps datasets to their XPT files)
|
|
129
|
+
leaves = {}
|
|
130
|
+
for el in mdv.iter():
|
|
131
|
+
if local(el.tag) == "leaf":
|
|
132
|
+
lid = el.get("ID")
|
|
133
|
+
href = el.get(XLINK_HREF)
|
|
134
|
+
if lid and href:
|
|
135
|
+
leaves[lid] = href
|
|
136
|
+
|
|
137
|
+
# def:MethodDef elements: the documented derivation algorithms.
|
|
138
|
+
methods = {}
|
|
139
|
+
for el in mdv.iter():
|
|
140
|
+
if local(el.tag) == "MethodDef":
|
|
141
|
+
oid = el.get("OID")
|
|
142
|
+
if not oid:
|
|
143
|
+
continue
|
|
144
|
+
methods[oid] = {
|
|
145
|
+
"oid": oid,
|
|
146
|
+
"name": el.get("Name") or "",
|
|
147
|
+
"type": el.get("Type") or "",
|
|
148
|
+
"description": desc_text(el),
|
|
149
|
+
"formal": formal_expression_text(el),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# ItemDef elements: variable-level metadata incl. def:Origin.
|
|
153
|
+
itemdefs = {}
|
|
154
|
+
for el in mdv.iter():
|
|
155
|
+
if local(el.tag) == "ItemDef":
|
|
156
|
+
oid = el.get("OID")
|
|
157
|
+
if not oid:
|
|
158
|
+
continue
|
|
159
|
+
origin_el = child(el, "Origin")
|
|
160
|
+
otype = ""
|
|
161
|
+
odesc = ""
|
|
162
|
+
if origin_el is not None:
|
|
163
|
+
otype = origin_el.get("Type") or ""
|
|
164
|
+
odesc = desc_text(origin_el)
|
|
165
|
+
itemdefs[oid] = {
|
|
166
|
+
"oid": oid,
|
|
167
|
+
"name": (el.get("Name") or "").upper(),
|
|
168
|
+
"label": desc_text(el),
|
|
169
|
+
"dtype": el.get("DataType") or "",
|
|
170
|
+
"length": el.get("Length") or "",
|
|
171
|
+
"origin_type": otype,
|
|
172
|
+
"origin_desc": odesc,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# ItemGroupDef elements: datasets and their ordered ItemRefs.
|
|
176
|
+
# Per the Define-XML 2.0 spec, an ItemRef carries MethodOID to link a
|
|
177
|
+
# variable to its derivation method (def:MethodDef).
|
|
178
|
+
datasets = {}
|
|
179
|
+
variables = {}
|
|
180
|
+
for ig in mdv:
|
|
181
|
+
if local(ig.tag) != "ItemGroupDef":
|
|
182
|
+
continue
|
|
183
|
+
name = (ig.get("Name") or "").upper()
|
|
184
|
+
if not name:
|
|
185
|
+
continue
|
|
186
|
+
cls = (def_attr(ig, "Class") or "").upper()
|
|
187
|
+
if "ADAM" in cls:
|
|
188
|
+
role = "ADAM"
|
|
189
|
+
elif name.startswith("AD"):
|
|
190
|
+
role = "ADAM" # ADSL, ADAE, ... (fallback heuristic)
|
|
191
|
+
else:
|
|
192
|
+
role = "SDTM"
|
|
193
|
+
leaf_id = def_attr(ig, "ArchiveLocationID")
|
|
194
|
+
href = leaves.get(leaf_id, "") if leaf_id else ""
|
|
195
|
+
xpt = os.path.basename(href) if href else (name.lower() + ".xpt")
|
|
196
|
+
|
|
197
|
+
var_order = []
|
|
198
|
+
for ir in children(ig, "ItemRef"):
|
|
199
|
+
item_oid = ir.get("ItemOID")
|
|
200
|
+
idef = itemdefs.get(item_oid)
|
|
201
|
+
if idef is None or not idef["name"]:
|
|
202
|
+
continue
|
|
203
|
+
# MethodOID is a plain attribute on ItemRef in the spec example;
|
|
204
|
+
# also accept a def-namespaced variant for tolerant parsing.
|
|
205
|
+
method_oid = ir.get("MethodOID") or def_attr(ir, "MethodOID") or ""
|
|
206
|
+
vname = idef["name"]
|
|
207
|
+
var_order.append(vname)
|
|
208
|
+
variables["%s.%s" % (name, vname)] = {
|
|
209
|
+
"dataset": name,
|
|
210
|
+
"name": vname,
|
|
211
|
+
"label": idef["label"],
|
|
212
|
+
"dtype": idef["dtype"],
|
|
213
|
+
"length": idef["length"],
|
|
214
|
+
"origin_type": idef["origin_type"],
|
|
215
|
+
"origin_desc": idef["origin_desc"],
|
|
216
|
+
"method_oid": method_oid,
|
|
217
|
+
}
|
|
218
|
+
datasets[name] = {
|
|
219
|
+
"name": name,
|
|
220
|
+
"label": desc_text(ig),
|
|
221
|
+
"domain": ig.get("Domain") or name,
|
|
222
|
+
"role": role,
|
|
223
|
+
"xpt": xpt,
|
|
224
|
+
"var_order": var_order,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return datasets, variables, methods
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# XPT reading
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def find_xpt_files(study_dir):
|
|
234
|
+
"""Map UPPER(file stem) -> path for every *.xpt under study_dir[/xpt]."""
|
|
235
|
+
found = {}
|
|
236
|
+
for base in (study_dir, os.path.join(study_dir, "xpt")):
|
|
237
|
+
if not os.path.isdir(base):
|
|
238
|
+
continue
|
|
239
|
+
for f in sorted(os.listdir(base)):
|
|
240
|
+
if f.lower().endswith(".xpt"):
|
|
241
|
+
found[os.path.splitext(f)[0].upper()] = os.path.join(base, f)
|
|
242
|
+
return found
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def read_xpt(path):
|
|
246
|
+
"""Return (upper-cased variable names, row count) for an XPT file."""
|
|
247
|
+
df, meta = pyreadstat.read_xport(path)
|
|
248
|
+
names = [str(c).upper() for c in meta.column_names]
|
|
249
|
+
return names, len(df)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Lineage: resolve each variable's predecessor variables
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
DOTTED_REF = re.compile(r"\b([A-Z][A-Z0-9]{0,7})\.([A-Z][A-Z0-9_]{0,7})\b")
|
|
257
|
+
BARE_TOKEN = re.compile(r"\b([A-Z][A-Z0-9_]{2,8})\b")
|
|
258
|
+
# Words that look like variable names but are just derivation prose.
|
|
259
|
+
STOPWORDS = {
|
|
260
|
+
"AND", "OR", "NOT", "IF", "THEN", "ELSE", "WHEN", "SET", "THE", "FOR",
|
|
261
|
+
"FROM", "WHERE", "WITH", "NULL", "INTO", "AS", "IS", "TO", "OF", "ON",
|
|
262
|
+
"BY", "AT", "BE", "AN", "DERIVED", "MAPPED", "VALUE", "VALUES",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def extract_refs(text, known_vars):
|
|
267
|
+
"""Find predecessor variable references in free derivation text.
|
|
268
|
+
|
|
269
|
+
Returns (refs, dangling):
|
|
270
|
+
refs - {"DS.VAR"} tokens that exist in the study metadata
|
|
271
|
+
dangling - {"DS.VAR"} tokens that look explicit but match nothing
|
|
272
|
+
Explicit dotted tokens (DM.AGE) are authoritative; bare tokens only
|
|
273
|
+
count when they uniquely match one known variable name.
|
|
274
|
+
"""
|
|
275
|
+
refs, dangling = set(), set()
|
|
276
|
+
if not text:
|
|
277
|
+
return refs, dangling
|
|
278
|
+
t = text.upper()
|
|
279
|
+
for m in DOTTED_REF.finditer(t):
|
|
280
|
+
key = "%s.%s" % (m.group(1), m.group(2))
|
|
281
|
+
if key in known_vars:
|
|
282
|
+
refs.add(key)
|
|
283
|
+
else:
|
|
284
|
+
dangling.add(key)
|
|
285
|
+
by_name = {}
|
|
286
|
+
for k in known_vars:
|
|
287
|
+
by_name.setdefault(k.split(".", 1)[1], []).append(k)
|
|
288
|
+
for m in BARE_TOKEN.finditer(t):
|
|
289
|
+
tok = m.group(1)
|
|
290
|
+
if tok in STOPWORDS:
|
|
291
|
+
continue
|
|
292
|
+
hits = by_name.get(tok, [])
|
|
293
|
+
if len(hits) == 1:
|
|
294
|
+
refs.add(hits[0])
|
|
295
|
+
return refs, dangling
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def resolve_predecessors(datasets, variables, methods):
|
|
299
|
+
"""For every variable, resolve predecessor variable keys.
|
|
300
|
+
|
|
301
|
+
Sources, in order: linked def:MethodDef text (via ItemRef/@MethodOID),
|
|
302
|
+
inline def:Origin description text, and Origin Type='Predecessor'
|
|
303
|
+
(same-named variable in an SDTM dataset).
|
|
304
|
+
Returns {varkey: {'refs': set, 'dangling': set, 'via': str}}.
|
|
305
|
+
"""
|
|
306
|
+
known_vars = set(variables)
|
|
307
|
+
sdtm_ds = [n for n, d in datasets.items() if d["role"] == "SDTM"]
|
|
308
|
+
sdtm_names = {}
|
|
309
|
+
for ds in sdtm_ds:
|
|
310
|
+
for v in datasets[ds]["var_order"]:
|
|
311
|
+
sdtm_names.setdefault(v, []).append(ds)
|
|
312
|
+
|
|
313
|
+
pred = {}
|
|
314
|
+
for vkey, var in variables.items():
|
|
315
|
+
refs, dangling, via = set(), set(), []
|
|
316
|
+
if var["method_oid"]:
|
|
317
|
+
m = methods.get(var["method_oid"])
|
|
318
|
+
if m:
|
|
319
|
+
r, d = extract_refs(
|
|
320
|
+
(m["description"] + " " + m["formal"]).strip(), known_vars)
|
|
321
|
+
refs |= r
|
|
322
|
+
dangling |= d
|
|
323
|
+
via.append("MethodDef %s" % m["oid"])
|
|
324
|
+
else:
|
|
325
|
+
via.append("MethodDef %s (missing!)" % var["method_oid"])
|
|
326
|
+
if var["origin_desc"]:
|
|
327
|
+
r, d = extract_refs(var["origin_desc"], known_vars)
|
|
328
|
+
refs |= r
|
|
329
|
+
dangling |= d
|
|
330
|
+
if r or d:
|
|
331
|
+
via.append("Origin description")
|
|
332
|
+
if var["origin_type"] == "Predecessor" and not refs:
|
|
333
|
+
# Fallback when the derivation text names no explicit source:
|
|
334
|
+
# link to the same-named variable in SDTM datasets.
|
|
335
|
+
for ds in sdtm_names.get(var["name"], []):
|
|
336
|
+
refs.add("%s.%s" % (ds, var["name"]))
|
|
337
|
+
if sdtm_names.get(var["name"]):
|
|
338
|
+
via.append("Origin=Predecessor")
|
|
339
|
+
refs.discard(vkey) # a variable is never its own predecessor
|
|
340
|
+
pred[vkey] = {"refs": refs, "dangling": dangling,
|
|
341
|
+
"via": "; ".join(via) if via else "none"}
|
|
342
|
+
return pred
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def reaches_sdtm(vkey, pred, variables, datasets):
|
|
346
|
+
"""True if following predecessor edges reaches any SDTM variable."""
|
|
347
|
+
seen = {vkey}
|
|
348
|
+
queue = deque([vkey])
|
|
349
|
+
while queue:
|
|
350
|
+
cur = queue.popleft()
|
|
351
|
+
for nxt in pred.get(cur, {}).get("refs", ()):
|
|
352
|
+
if nxt in seen:
|
|
353
|
+
continue
|
|
354
|
+
seen.add(nxt)
|
|
355
|
+
ds = variables[nxt]["dataset"]
|
|
356
|
+
if datasets[ds]["role"] == "SDTM":
|
|
357
|
+
return True
|
|
358
|
+
queue.append(nxt)
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# Gap detection
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# (a) variable in XPT but missing from define.xml
|
|
366
|
+
# (b) variable in define.xml but missing from XPT
|
|
367
|
+
# (c) ADaM variable with Origin='Derived' but no documented derivation
|
|
368
|
+
# (d) derivation references a variable that exists nowhere
|
|
369
|
+
# (e) ADaM variable with no lineage path back to any SDTM variable
|
|
370
|
+
|
|
371
|
+
GAP_LABELS = {
|
|
372
|
+
"XPT_ONLY": "In XPT, missing from define.xml",
|
|
373
|
+
"DEFINE_ONLY": "In define.xml, missing from XPT",
|
|
374
|
+
"DERIVED_NO_METHOD": "Origin=Derived but no derivation documented",
|
|
375
|
+
"DANGLING_REF": "Derivation references undefined variable",
|
|
376
|
+
"NO_SDTM_PATH": "No lineage path back to any SDTM variable",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def method_documented(var, methods):
|
|
381
|
+
"""A derivation counts as documented with a linked MethodDef that has
|
|
382
|
+
text, or with an inline Origin description."""
|
|
383
|
+
if var["method_oid"]:
|
|
384
|
+
m = methods.get(var["method_oid"])
|
|
385
|
+
if m and (m["description"] or m["formal"]):
|
|
386
|
+
return True
|
|
387
|
+
return bool(var["origin_desc"].strip())
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def detect_gaps(datasets, variables, methods, pred, xpt_vars):
|
|
391
|
+
gaps = []
|
|
392
|
+
|
|
393
|
+
def add(code, dataset, variable, detail):
|
|
394
|
+
gaps.append({"code": code, "label": GAP_LABELS[code],
|
|
395
|
+
"dataset": dataset, "variable": variable,
|
|
396
|
+
"detail": detail})
|
|
397
|
+
|
|
398
|
+
# (a) + (b): define.xml vs XPT agreement, per dataset
|
|
399
|
+
for dsname, ds in datasets.items():
|
|
400
|
+
stem = os.path.splitext(ds["xpt"])[0].upper()
|
|
401
|
+
actual = xpt_vars.get(stem)
|
|
402
|
+
defined = set(ds["var_order"])
|
|
403
|
+
if actual is None:
|
|
404
|
+
add("DEFINE_ONLY", dsname, "(dataset)",
|
|
405
|
+
"define.xml dataset has no matching XPT file (%s)" % ds["xpt"])
|
|
406
|
+
continue
|
|
407
|
+
actual_set = set(actual)
|
|
408
|
+
for v in sorted(actual_set - defined):
|
|
409
|
+
add("XPT_ONLY", dsname, v,
|
|
410
|
+
"present in %s but not defined in define.xml" % ds["xpt"])
|
|
411
|
+
for v in sorted(defined - actual_set):
|
|
412
|
+
add("DEFINE_ONLY", dsname, v,
|
|
413
|
+
"defined in define.xml but missing from %s" % ds["xpt"])
|
|
414
|
+
|
|
415
|
+
# (c), (d), (e): derivation-level checks
|
|
416
|
+
for vkey, var in variables.items():
|
|
417
|
+
ds = var["dataset"]
|
|
418
|
+
if datasets[ds]["role"] != "ADAM":
|
|
419
|
+
continue
|
|
420
|
+
if var["origin_type"] == "Derived" and not method_documented(var, methods):
|
|
421
|
+
add("DERIVED_NO_METHOD", ds, var["name"],
|
|
422
|
+
"Origin is 'Derived' but no MethodDef/description documents how")
|
|
423
|
+
for d in sorted(pred[vkey]["dangling"]):
|
|
424
|
+
add("DANGLING_REF", ds, var["name"],
|
|
425
|
+
"derivation references %s, which is defined nowhere" % d)
|
|
426
|
+
if not reaches_sdtm(vkey, pred, variables, datasets):
|
|
427
|
+
add("NO_SDTM_PATH", ds, var["name"],
|
|
428
|
+
"no predecessor chain reaches any SDTM variable")
|
|
429
|
+
|
|
430
|
+
# stable ordering: by gap type, then dataset, then variable
|
|
431
|
+
order = list(GAP_LABELS)
|
|
432
|
+
gaps.sort(key=lambda g: (order.index(g["code"]), g["dataset"], g["variable"]))
|
|
433
|
+
return gaps
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# HTML report (single self-contained file: inline SVG + JS, no CDN)
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
CSS = """
|
|
440
|
+
body{font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;
|
|
441
|
+
margin:0;background:#f6f8fb;color:#1c2733}
|
|
442
|
+
header{background:#0f2a44;color:#fff;padding:22px 32px}
|
|
443
|
+
header h1{margin:0;font-size:22px} header p{margin:6px 0 0;color:#b9c8da}
|
|
444
|
+
main{padding:24px 32px;max-width:1400px}
|
|
445
|
+
.cards{display:flex;gap:14px;flex-wrap:wrap;margin-bottom:22px}
|
|
446
|
+
.card{background:#fff;border:1px solid #dfe6ef;border-radius:10px;
|
|
447
|
+
padding:14px 20px;min-width:150px}
|
|
448
|
+
.card .n{font-size:26px;font-weight:700}.card .l{font-size:12px;color:#5b6b7f}
|
|
449
|
+
.card.bad .n{color:#c0392b}
|
|
450
|
+
h2{font-size:17px;margin:26px 0 10px}
|
|
451
|
+
table{border-collapse:collapse;width:100%;background:#fff;font-size:13px}
|
|
452
|
+
th,td{border:1px solid #dfe6ef;padding:8px 10px;text-align:left;vertical-align:top}
|
|
453
|
+
th{background:#eaf0f7}
|
|
454
|
+
.badge{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11px;
|
|
455
|
+
font-weight:700;white-space:nowrap}
|
|
456
|
+
.XPT_ONLY{background:#fdebd0;color:#935116}
|
|
457
|
+
.DEFINE_ONLY{background:#f9ebea;color:#922b21}
|
|
458
|
+
.DERIVED_NO_METHOD{background:#f5cba7;color:#7e5109}
|
|
459
|
+
.DANGLING_REF{background:#fadbd8;color:#943126}
|
|
460
|
+
.NO_SDTM_PATH{background:#e8daef;color:#6c3483}
|
|
461
|
+
#graph{background:#fff;border:1px solid #dfe6ef;border-radius:10px;
|
|
462
|
+
overflow:auto;margin-top:6px}
|
|
463
|
+
.node{cursor:pointer}
|
|
464
|
+
.node rect{fill:#fff;stroke:#7f9bb3;stroke-width:1.5;rx:6}
|
|
465
|
+
.node text{font-size:12px;fill:#1c2733}
|
|
466
|
+
.node.gapvar rect{stroke:#c0392b;stroke-width:2.5}
|
|
467
|
+
.node.xptonly rect{stroke-dasharray:6 4;fill:#fdf6ec}
|
|
468
|
+
.node.dim{opacity:.18}.edge.dim{opacity:.08}
|
|
469
|
+
.node.hl rect{stroke:#0f2a44;stroke-width:3}
|
|
470
|
+
.edge{stroke:#7f9bb3;stroke-width:1.5;fill:none;opacity:.75}
|
|
471
|
+
.edge.hl{stroke:#0f2a44;stroke-width:3;opacity:1}
|
|
472
|
+
.dslabel{font-size:14px;font-weight:700;fill:#0f2a44}
|
|
473
|
+
#detail{background:#fff;border:1px solid #dfe6ef;border-radius:10px;
|
|
474
|
+
padding:14px 18px;margin-top:14px;font-size:13px;min-height:90px}
|
|
475
|
+
#detail h3{margin:0 0 8px;font-size:15px}
|
|
476
|
+
.kv{color:#5b6b7f}.legend{font-size:12px;color:#5b6b7f;margin-top:8px}
|
|
477
|
+
footer{padding:18px 32px;color:#8a97a8;font-size:12px}
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
JS = """
|
|
481
|
+
function clearHl(){
|
|
482
|
+
document.querySelectorAll('.node.hl,.edge.hl,.node.dim,.edge.dim')
|
|
483
|
+
.forEach(function(e){e.classList.remove('hl','dim')});
|
|
484
|
+
document.getElementById('detail').innerHTML='<span class="kv">Click a variable node to inspect its lineage.</span>';
|
|
485
|
+
}
|
|
486
|
+
function showDetail(id){
|
|
487
|
+
var v=VAR_INFO[id]; if(!v) return;
|
|
488
|
+
var h='<h3>'+v.key+'</h3>';
|
|
489
|
+
h+='<div><span class="kv">Label:</span> '+v.label+'</div>';
|
|
490
|
+
h+='<div><span class="kv">Origin:</span> '+v.origin+'</div>';
|
|
491
|
+
h+='<div><span class="kv">Method:</span> '+v.method+'</div>';
|
|
492
|
+
h+='<div><span class="kv">Predecessors:</span> '+(v.preds.length?v.preds.join(', '):'<i>none</i>')+'</div>';
|
|
493
|
+
h+='<div><span class="kv">Resolved via:</span> '+v.via+'</div>';
|
|
494
|
+
if(v.gaps.length){h+='<div style="margin-top:6px"><span class="kv">Gaps:</span><ul style="margin:4px 0">';
|
|
495
|
+
v.gaps.forEach(function(g){h+='<li><b>'+g.code+'</b> - '+g.detail+'</li>'});h+='</ul></div>'}
|
|
496
|
+
document.getElementById('detail').innerHTML=h;
|
|
497
|
+
}
|
|
498
|
+
document.querySelectorAll('.node').forEach(function(n){
|
|
499
|
+
n.addEventListener('click',function(ev){
|
|
500
|
+
ev.stopPropagation();
|
|
501
|
+
var id=n.id, wasHl=n.classList.contains('hl');
|
|
502
|
+
clearHl(); if(wasHl) return;
|
|
503
|
+
var keep={}; keep[id]=1;
|
|
504
|
+
document.querySelectorAll('.edge').forEach(function(e){
|
|
505
|
+
if(e.dataset.from===id||e.dataset.to===id){
|
|
506
|
+
e.classList.add('hl');keep[e.dataset.from]=1;keep[e.dataset.to]=1;
|
|
507
|
+
} else e.classList.add('dim');
|
|
508
|
+
});
|
|
509
|
+
document.querySelectorAll('.node').forEach(function(m){
|
|
510
|
+
if(keep[m.id]) m.classList.add('hl'); else m.classList.add('dim');
|
|
511
|
+
});
|
|
512
|
+
showDetail(id);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
document.getElementById('svgroot').addEventListener('click',clearHl);
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def node_id(key):
|
|
520
|
+
return "n-" + key.replace(".", "-")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def build_html(study_name, define_name, datasets, variables, methods, pred,
|
|
524
|
+
edges, gaps, xpt_vars, xpt_rows):
|
|
525
|
+
esc = html_lib.escape
|
|
526
|
+
n_vars = len(variables)
|
|
527
|
+
n_edges = len(edges)
|
|
528
|
+
n_gaps = len(gaps)
|
|
529
|
+
by_type = {}
|
|
530
|
+
for g in gaps:
|
|
531
|
+
by_type[g["code"]] = by_type.get(g["code"], 0) + 1
|
|
532
|
+
|
|
533
|
+
# ---- layout: one column per dataset (SDTM left, ADaM right) ----
|
|
534
|
+
sdtm = sorted([n for n, d in datasets.items() if d["role"] == "SDTM"])
|
|
535
|
+
adam = sorted([n for n, d in datasets.items() if d["role"] != "SDTM"])
|
|
536
|
+
cols = sdtm + adam
|
|
537
|
+
NW, NH, VGAP, COLW, TOP, LEFT = 230, 30, 12, 300, 100, 40
|
|
538
|
+
pos = {} # varkey -> (x, y)
|
|
539
|
+
for ci, ds in enumerate(cols):
|
|
540
|
+
x = LEFT + ci * COLW
|
|
541
|
+
# define vars first (in define order), then XPT-only vars (dashed)
|
|
542
|
+
keys = ["%s.%s" % (ds, v) for v in datasets[ds]["var_order"]]
|
|
543
|
+
stem = os.path.splitext(datasets[ds]["xpt"])[0].upper()
|
|
544
|
+
defined = set(datasets[ds]["var_order"])
|
|
545
|
+
for v in (xpt_vars.get(stem) or []):
|
|
546
|
+
if v not in defined:
|
|
547
|
+
keys.append("%s.%s" % (ds, v))
|
|
548
|
+
for i, k in enumerate(keys):
|
|
549
|
+
pos[k] = (x, TOP + i * (NH + VGAP))
|
|
550
|
+
ncols = max(len(cols), 1)
|
|
551
|
+
maxrows = max((len([k for k in pos if k.startswith(c + ".")])
|
|
552
|
+
for c in cols), default=1)
|
|
553
|
+
W = LEFT * 2 + ncols * COLW
|
|
554
|
+
H = TOP + maxrows * (NH + VGAP) + 50
|
|
555
|
+
|
|
556
|
+
# ---- SVG ----
|
|
557
|
+
svg = ['<svg id="svgroot" width="%d" height="%d" '
|
|
558
|
+
'xmlns="http://www.w3.org/2000/svg">' % (W, H)]
|
|
559
|
+
for ci, ds in enumerate(cols):
|
|
560
|
+
x = LEFT + ci * COLW
|
|
561
|
+
svg.append('<text class="dslabel" x="%d" y="40">%s</text>' % (x, esc(ds)))
|
|
562
|
+
svg.append('<text x="%d" y="60" font-size="11" fill="#5b6b7f">%s - %s</text>'
|
|
563
|
+
% (x, esc(datasets[ds]["role"]),
|
|
564
|
+
esc(datasets[ds]["xpt"])))
|
|
565
|
+
gap_vars = {g["dataset"] + "." + g["variable"] for g in gaps}
|
|
566
|
+
for k, (x, y) in pos.items():
|
|
567
|
+
nid = node_id(k)
|
|
568
|
+
cls = "node"
|
|
569
|
+
if k in gap_vars:
|
|
570
|
+
cls += " gapvar"
|
|
571
|
+
if k not in variables:
|
|
572
|
+
cls += " xptonly"
|
|
573
|
+
short = k if len(k) <= 26 else k[:25] + "…"
|
|
574
|
+
svg.append(
|
|
575
|
+
'<g class="%s" id="%s"><rect x="%d" y="%d" width="%d" height="%d" rx="6"/>'
|
|
576
|
+
'<text x="%d" y="%d">%s</text></g>'
|
|
577
|
+
% (cls, nid, x, y, NW, NH, x + 10, y + 20, esc(short)))
|
|
578
|
+
for (a, b) in edges:
|
|
579
|
+
if a not in pos or b not in pos:
|
|
580
|
+
continue
|
|
581
|
+
x1, y1 = pos[a][0] + NW, pos[a][1] + NH / 2
|
|
582
|
+
x2, y2 = pos[b][0], pos[b][1] + NH / 2
|
|
583
|
+
mx = (x1 + x2) / 2
|
|
584
|
+
svg.append(
|
|
585
|
+
'<path class="edge" data-from="%s" data-to="%s" '
|
|
586
|
+
'd="M %d %d C %d %d, %d %d, %d %d"/>'
|
|
587
|
+
% (node_id(a), node_id(b), x1, y1, mx, y1, mx, y2, x2, y2))
|
|
588
|
+
svg.append("</svg>")
|
|
589
|
+
svg_html = "\n".join(svg)
|
|
590
|
+
|
|
591
|
+
# ---- gap table ----
|
|
592
|
+
rows = []
|
|
593
|
+
for i, g in enumerate(gaps, 1):
|
|
594
|
+
rows.append(
|
|
595
|
+
"<tr><td>%d</td><td><span class='badge %s'>%s</span></td>"
|
|
596
|
+
"<td>%s</td><td><b>%s</b></td><td>%s</td></tr>"
|
|
597
|
+
% (i, g["code"], esc(g["code"]), esc(g["dataset"]),
|
|
598
|
+
esc(g["variable"]), esc(g["detail"])))
|
|
599
|
+
gap_table = ("\n".join(rows) if rows
|
|
600
|
+
else "<tr><td colspan='5'>No gaps found.</td></tr>")
|
|
601
|
+
|
|
602
|
+
# ---- JS detail data ----
|
|
603
|
+
gaps_by_var = {}
|
|
604
|
+
for g in gaps:
|
|
605
|
+
gaps_by_var.setdefault(g["dataset"] + "." + g["variable"], []).append(
|
|
606
|
+
{"code": g["code"], "detail": g["detail"]})
|
|
607
|
+
var_info = {}
|
|
608
|
+
for k, v in variables.items():
|
|
609
|
+
m = methods.get(v["method_oid"]) if v["method_oid"] else None
|
|
610
|
+
var_info[node_id(k)] = {
|
|
611
|
+
"key": k,
|
|
612
|
+
"label": esc(v["label"] or "-"),
|
|
613
|
+
"origin": esc(v["origin_type"] or "-"),
|
|
614
|
+
"method": esc(("%s: %s" % (m["oid"], m["description"] or m["formal"] or "-"))
|
|
615
|
+
if m else ("(%s, undocumented)" % v["method_oid"]
|
|
616
|
+
if v["method_oid"] else "-")),
|
|
617
|
+
"preds": sorted(pred[k]["refs"]),
|
|
618
|
+
"via": esc(pred[k]["via"]),
|
|
619
|
+
"gaps": gaps_by_var.get(k, []),
|
|
620
|
+
}
|
|
621
|
+
for k in pos:
|
|
622
|
+
if k not in variables: # XPT-only nodes
|
|
623
|
+
ds, vn = k.split(".", 1)
|
|
624
|
+
var_info[node_id(k)] = {
|
|
625
|
+
"key": k, "label": "-", "origin": "not in define.xml",
|
|
626
|
+
"method": "-", "preds": [], "via": "none",
|
|
627
|
+
"gaps": gaps_by_var.get(k, []),
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
cards = [
|
|
631
|
+
("Datasets", len(datasets), ""),
|
|
632
|
+
("Variables (define.xml)", n_vars, ""),
|
|
633
|
+
("XPT files read", len(xpt_vars), ""),
|
|
634
|
+
("Lineage edges", n_edges, ""),
|
|
635
|
+
("Traceability gaps", n_gaps, "bad" if n_gaps else ""),
|
|
636
|
+
]
|
|
637
|
+
cards_html = "".join(
|
|
638
|
+
'<div class="card %s"><div class="n">%d</div><div class="l">%s</div></div>'
|
|
639
|
+
% (c, n, l) for l, n, c in cards)
|
|
640
|
+
|
|
641
|
+
type_rows = "".join(
|
|
642
|
+
"<tr><td><span class='badge %s'>%s</span></td><td>%s</td><td>%d</td></tr>"
|
|
643
|
+
% (code, esc(code), esc(GAP_LABELS[code]), by_type.get(code, 0))
|
|
644
|
+
for code in GAP_LABELS)
|
|
645
|
+
|
|
646
|
+
return ("<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
|
647
|
+
"<title>Traceability report - %s</title><style>%s</style></head><body>"
|
|
648
|
+
"<header><h1>Traceability report: %s</h1>"
|
|
649
|
+
"<p>define.xml: %s | generated by trace.py</p></header><main>"
|
|
650
|
+
"<div class='cards'>%s</div>"
|
|
651
|
+
"<h2>Gaps by type</h2><table><tr><th>Type</th><th>Meaning</th><th>Count</th></tr>%s</table>"
|
|
652
|
+
"<h2>Gap details (%d)</h2>"
|
|
653
|
+
"<table><tr><th>#</th><th>Type</th><th>Dataset</th><th>Variable</th><th>Detail</th></tr>%s</table>"
|
|
654
|
+
"<h2>Lineage graph</h2>"
|
|
655
|
+
"<div class='legend'>SDTM datasets on the left, ADaM on the right. "
|
|
656
|
+
"Red border = variable has gaps. Dashed = in XPT but not in define.xml. "
|
|
657
|
+
"Click a node to highlight its lineage.</div>"
|
|
658
|
+
"<div id='graph'>%s</div><div id='detail'>"
|
|
659
|
+
"<span class='kv'>Click a variable node to inspect its lineage.</span></div>"
|
|
660
|
+
"</main><footer>trace.py v1 prototype - variable-level lineage from "
|
|
661
|
+
"define.xml + XPT</footer>"
|
|
662
|
+
"<script>var VAR_INFO = %s;</script><script>%s</script>"
|
|
663
|
+
"</body></html>"
|
|
664
|
+
% (esc(study_name), CSS, esc(study_name), esc(define_name),
|
|
665
|
+
cards_html, type_rows, n_gaps, gap_table, svg_html,
|
|
666
|
+
json.dumps(var_info), JS))
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ---------------------------------------------------------------------------
|
|
670
|
+
# CLI
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
def find_define(study_dir):
|
|
674
|
+
for f in sorted(os.listdir(study_dir)):
|
|
675
|
+
if f.lower() == "define.xml":
|
|
676
|
+
return os.path.join(study_dir, f)
|
|
677
|
+
raise FileNotFoundError("No define.xml found in %s" % study_dir)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def main(argv=None):
|
|
681
|
+
ap = argparse.ArgumentParser(
|
|
682
|
+
description="Build a variable-level lineage graph from define.xml + "
|
|
683
|
+
"XPT and flag traceability gaps.")
|
|
684
|
+
ap.add_argument("--study", required=True,
|
|
685
|
+
help="Study folder with define.xml and *.xpt files")
|
|
686
|
+
ap.add_argument("--out", required=True, help="Output HTML report path")
|
|
687
|
+
args = ap.parse_args(argv)
|
|
688
|
+
|
|
689
|
+
study = args.study
|
|
690
|
+
define_path = find_define(study)
|
|
691
|
+
datasets, variables, methods = parse_define(define_path)
|
|
692
|
+
|
|
693
|
+
xpt_files = find_xpt_files(study)
|
|
694
|
+
xpt_vars, xpt_rows = {}, {}
|
|
695
|
+
for stem, path in sorted(xpt_files.items()):
|
|
696
|
+
try:
|
|
697
|
+
names, nrows = read_xpt(path)
|
|
698
|
+
except Exception as e: # noqa: BLE001 - report and continue
|
|
699
|
+
print("warning: could not read %s: %s" % (path, e), file=sys.stderr)
|
|
700
|
+
continue
|
|
701
|
+
xpt_vars[stem] = names
|
|
702
|
+
xpt_rows[stem] = nrows
|
|
703
|
+
|
|
704
|
+
pred = resolve_predecessors(datasets, variables, methods)
|
|
705
|
+
gaps = detect_gaps(datasets, variables, methods, pred, xpt_vars)
|
|
706
|
+
edges = sorted({(v, r) for v, p in pred.items() for r in p["refs"]})
|
|
707
|
+
|
|
708
|
+
with open(args.out, "w", encoding="utf-8") as f:
|
|
709
|
+
f.write(build_html(os.path.basename(os.path.abspath(study)),
|
|
710
|
+
os.path.basename(define_path),
|
|
711
|
+
datasets, variables, methods, pred, edges, gaps,
|
|
712
|
+
xpt_vars, xpt_rows))
|
|
713
|
+
|
|
714
|
+
# ---- console summary ----
|
|
715
|
+
print("Study: %s" % study)
|
|
716
|
+
print("define.xml: %d datasets, %d variables, %d methods"
|
|
717
|
+
% (len(datasets), len(variables), len(methods)))
|
|
718
|
+
for stem in sorted(xpt_vars):
|
|
719
|
+
print(" XPT %-8s %3d vars %4d rows"
|
|
720
|
+
% (stem, len(xpt_vars[stem]), xpt_rows[stem]))
|
|
721
|
+
print("Lineage edges: %d" % len(edges))
|
|
722
|
+
print("Gaps: %d" % len(gaps))
|
|
723
|
+
for g in gaps:
|
|
724
|
+
print(" [%s] %s.%s -- %s"
|
|
725
|
+
% (g["code"], g["dataset"], g["variable"], g["detail"]))
|
|
726
|
+
print("Report written to %s" % args.out)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
if __name__ == "__main__":
|
|
730
|
+
main()
|