cpp-xer-parser 0.6.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 (31) hide show
  1. cpp_xer_parser-0.6.1/CHANGELOG.md +225 -0
  2. cpp_xer_parser-0.6.1/LICENSE +21 -0
  3. cpp_xer_parser-0.6.1/MANIFEST.in +1 -0
  4. cpp_xer_parser-0.6.1/PKG-INFO +287 -0
  5. cpp_xer_parser-0.6.1/README.md +258 -0
  6. cpp_xer_parser-0.6.1/cpp_xer_parser.egg-info/PKG-INFO +287 -0
  7. cpp_xer_parser-0.6.1/cpp_xer_parser.egg-info/SOURCES.txt +29 -0
  8. cpp_xer_parser-0.6.1/cpp_xer_parser.egg-info/dependency_links.txt +1 -0
  9. cpp_xer_parser-0.6.1/cpp_xer_parser.egg-info/entry_points.txt +2 -0
  10. cpp_xer_parser-0.6.1/cpp_xer_parser.egg-info/top_level.txt +1 -0
  11. cpp_xer_parser-0.6.1/pyproject.toml +46 -0
  12. cpp_xer_parser-0.6.1/scripts/__init__.py +38 -0
  13. cpp_xer_parser-0.6.1/scripts/config_profiles.py +131 -0
  14. cpp_xer_parser-0.6.1/scripts/validation.py +164 -0
  15. cpp_xer_parser-0.6.1/scripts/xer_parser.py +3468 -0
  16. cpp_xer_parser-0.6.1/setup.cfg +4 -0
  17. cpp_xer_parser-0.6.1/tests/test_bundled_validation_runs.py +214 -0
  18. cpp_xer_parser-0.6.1/tests/test_calendar_map_default_flag_2026_09_19.py +42 -0
  19. cpp_xer_parser-0.6.1/tests/test_calendar_resolution_block_2026_09_19.py +72 -0
  20. cpp_xer_parser-0.6.1/tests/test_calendar_shift_hours_2026_10_10.py +137 -0
  21. cpp_xer_parser-0.6.1/tests/test_calendar_slot_order_2026_09_29.py +267 -0
  22. cpp_xer_parser-0.6.1/tests/test_corrupt_calendar_record_2026_10_28.py +320 -0
  23. cpp_xer_parser-0.6.1/tests/test_half_step.py +623 -0
  24. cpp_xer_parser-0.6.1/tests/test_no_fabricated_citations.py +110 -0
  25. cpp_xer_parser-0.6.1/tests/test_p6_rules_2026_10_13.py +224 -0
  26. cpp_xer_parser-0.6.1/tests/test_packaging.py +60 -0
  27. cpp_xer_parser-0.6.1/tests/test_report_summary_2026_09_29.py +169 -0
  28. cpp_xer_parser-0.6.1/tests/test_task_calendar_resolution_2026_09_18.py +265 -0
  29. cpp_xer_parser-0.6.1/tests/test_workday_arithmetic_2026_09_29.py +235 -0
  30. cpp_xer_parser-0.6.1/tests/test_writer_and_summary_2026_09_29.py +229 -0
  31. cpp_xer_parser-0.6.1/tests/test_xer_parser.py +222 -0
@@ -0,0 +1,225 @@
1
+ # Changelog
2
+
3
+ All notable changes to `cpp-xer-parser` are documented here. Versioning follows [Semantic Versioning](https://semver.org).
4
+
5
+ ---
6
+
7
+ ## v0.6.1 — 2026-11-09
8
+
9
+ The parser installs from PyPI: `pip install cpp-xer-parser`. Nothing it computes changes.
10
+
11
+ ### Added
12
+
13
+ - **Packaging** (`pyproject.toml`). Installed, `scripts/` is the package `cpp_xer_parser`, so `from cpp_xer_parser import parse_xer` works and the generic module names (`validation`, `config_profiles`) do not land at the top level of an environment. The command `cpp-xer-parser` runs the parser's command line. A clone with `scripts/` on `sys.path` works as before: `xer_parser.py` binds its two helpers from the package when installed, else as top-level modules.
14
+ - **Release workflow** (`.github/workflows/publish.yml`). Publishing a GitHub release builds the sdist and wheel, checks the release tag names the version in `pyproject.toml`, and uploads to PyPI by trusted publishing, so no API token is stored. CI also builds the wheel, installs it in a clean environment and runs the command on every push.
15
+
16
+ ## v0.6.0 — 2026-11-08
17
+
18
+ The DCMA-14 profiles in `config_profiles.py` now carry the thresholds CPP's own tools use, as CPP's written DCMA-14 definition sets them ([criticalpathpartners.ca/dcma-14-definition.html](https://criticalpathpartners.ca/dcma-14-definition.html)). The file is identical to the copy [`cpp-critical-path-validator`](https://github.com/danafitkowski/cpp-critical-path-validator) bundles. The parser itself (`xer_parser.py`) is unchanged.
19
+
20
+ **Why a minor release.** Before 1.0 this project moves the minor number for a release that changes results a caller already reads. A caller that grades DCMA-14 with these profiles sees #10 move on every profile, and the nuclear and mining profiles move on the checks listed below.
21
+
22
+ ### Changed
23
+
24
+ - **DCMA-14 #10 Resources passes only at 100% on every profile** (`config_profiles.py`: commercial 80, nuclear 90 and mining 70 become 100). Every incomplete activity of at least one working day must carry a resource assignment with hours or cost, as CPP's written DCMA-14 definition says ([criticalpathpartners.ca/dcma-14-definition.html](https://criticalpathpartners.ca/dcma-14-definition.html)), and the check is not scored when a file carries no assignments. The file is now identical to the copy [`cpp-critical-path-validator`](https://github.com/danafitkowski/cpp-critical-path-validator) bundles.
25
+ - **The nuclear and mining profiles carry the thresholds CPP's own tools use** (`config_profiles.py`; 14 values differed). Nuclear: #1 1% (was 2%), #3 2% (3%), #5 2% (3%), #6 over 30 working days (44), #8 over 20 working days (30). Mining: #1 5% (8%), #3 10% (8%), #6 over 90 working days and 10% (60, 8%), #8 over 90 working days and 10% (60, 8%), #11 5% (8%), #13 0.93 (0.90), #14 0.92 (0.90). The commercial profile is unchanged. The file is still identical to the copy `cpp-critical-path-validator` bundles.
26
+
27
+ ## v0.5.0 — 2026-10-28
28
+
29
+ A corrupt calendar record is read the way P6 schedules it: on P6's internal Standard calendar, not on the week the record declares.
30
+
31
+ **Why a minor release.** Before 1.0 this project moves the minor number for a release that changes results a caller already reads. `get_calendar_map` can now give a corrupt record a different calendar: its working days, its holidays, its `hours_per_day`, and so every working-day count and hours-to-days conversion on it. Nothing moves for any other record, a finish-first one with a legal `clndr_type` included.
32
+
33
+ ### Changed
34
+
35
+ - **A corrupt calendar record is P6's internal Standard.** A record is corrupt when (a) its DaysOfWeek block writes at least one shift slot finish-first, `(f|HH:MM|s|HH:MM)`, and (b) its `clndr_type` is not a legal P6 token (`CA_Base`, `CA_Rsrc`, `CA_Project`). The corrupt record seen in real exports says `CT_Project` where every legitimate project calendar says `CA_Project`. P6 cannot bind such a record and schedules the activities assigned to it on its internal Standard: Monday to Friday, eight hours a day, no exceptions. P6's own stored dates in real exports carrying the record show it: every one of 130 unambiguous not-started spans on the calendar walks Monday to Friday, no forecast date lands on the Saturday the record declares working, statutory holidays are worked, and every finish closes at the default calendar's closing time. v0.4.0 read the record as declared, so a record declaring Monday to Saturday counted Saturdays P6 does not work.
36
+
37
+ `get_calendar_map` now gives such a record Monday to Friday, no holidays, `hours_per_day` 8 whatever `day_hr_cnt` says (40 hours on a record declaring 10 is five days, as P6 schedules it), and `hours_per_week` 40. The screen is `cpp-cpm-engine`'s own `decode_clndr_data` and `decode_calendar_record`, the decoder its `parseXER` applies, copied into the parser unchanged so it runs without the engine installed.
38
+
39
+ - **Finish-first slots alone are not corruption.** Records with a legal `clndr_type` and finish-first slots were shown genuine by P6's own stored dates (their declared week walked, their own holidays kept), and they decode as declared, as before. So does a record with an illegal type and start-first slots: both conditions are needed.
40
+
41
+ - **A finish-first record with no `clndr_type` takes the fallback.** A blank or missing type is not a legal token, so it meets condition (b), as it does in the engine. A genuine P6 export always writes the type; a hand-built or converted file that leaves it out and writes finish-first slots now reads as the Standard, with the warning.
42
+
43
+ ### Added
44
+
45
+ - **`p6_fallback`** (True) and **`corrupt_record_alert`** on the calendar-map entry of a corrupt record: `{severity: 'ALERT', context: 'calendar-corrupt-p6-fallback', message}`, the message naming the calendar, the type it holds and the week it declares. A report computed on the fallback should disclose it.
46
+ - **`calendar_corrupt_fallbacks(cal_map)`** lists those alerts, one per corrupt record. Empty when none.
47
+ - **`CalendarCorruptRecordWarning`**, a `UserWarning` subclass `get_calendar_map` raises once per corrupt record, naming it and its type.
48
+
49
+ ### Testing
50
+
51
+ - `tests/test_corrupt_calendar_record_2026_10_28.py` has 12 tests, all synthetic. Seven fail on v0.4.0; four pin what must not move (finish-first records with each legal type, an illegal type on start-first slots, a clean record, an empty record); one compares the parser's decoder with `cpp-cpm-engine`'s, field for field and alert for alert, when the engine's `python_reference` directory is on `sys.path`. CI runs the file under pytest and directly.
52
+ - `tests/test_calendar_slot_order_2026_09_29.py`: the fixture that decodes both slot orders from one file now carries the `CA_Base` type a genuine export writes.
53
+ - `pytest tests/` is 142 tests across 14 files: 140 pass and the two engine-parity tests skip, and all 142 pass with `cpp-cpm-engine`'s `python_reference` on `sys.path` (at v2.10.1 and at v2.10.14).
54
+
55
+ ---
56
+
57
+ ## v0.4.0 — 2026-10-13
58
+
59
+ The data date and the calendars are read the way P6 schedules from them. Each rule was measured on Primavera P6 Professional 23.12.1's own F9, and each is the one `cpp-cpm-engine` v2.10.1's `parseXER` applies.
60
+
61
+ **Why a minor release.** Before 1.0 this project moves the minor number for a release that changes results a caller already reads. `parse_xer` can now hand back a different `PROJECT.last_recalc_date`, and `get_calendar_map` a different calendar for an empty record: its working days, `hours_per_day`, and so every hours-to-days conversion on it. Nothing moves for a project whose data date is filed on or after the day of its planned start, or for a calendar record that carries its week.
62
+
63
+ ### Changed
64
+
65
+ - **A blank data date is the project's planned start.** P6 schedules "from the project data date, using the project start date as the initial data date". `parse_xer` fills a blank `PROJECT.last_recalc_date` from `plan_start_date` in place, so every consumer reads the date P6 schedules from. A blank with no planned start stays blank.
66
+ - **A data date filed on an earlier day than the planned start is lifted to it.** P6 lifts the data date to a later planned start when it opens the project, and schedules from there: with a data date of 28 August and a planned start of 1 September, P6 shows and schedules from 1 September. A planned start later the same day is left as filed.
67
+ - **An empty calendar record is P6's internal Standard.** A record with no `clndr_data` is scheduled Monday to Friday, 08:00-16:00, eight hours a day, with no holidays, whatever `day_hr_cnt` declares: records declaring 10, 6 and 24 hours ran the same 08:00-16:00 days, and a 40-hour task from a Friday worked through the Labour Day holiday the record's base calendar carries. v0.3.0 gave such a record no working days and converted hours at the declared figure, so 40 hours on a record declaring 10 read as four days where P6 schedules five.
68
+
69
+ ### Added
70
+
71
+ - **`data['data_date_filled']`**: one entry per project whose data date `parse_xer` changed, `{proj_id, last_recalc_date, filled_from: 'plan_start_date'}`, plus `filed_data_date` when a filed date was lifted. Empty when nothing changed. A report can use it to say the data date was not the file's own.
72
+ - **`empty_record`** (True) and **`declared_hours_per_day`** on the calendar-map entry of an empty record. `calendar_shift_hours` does not list it: its hours were not taken from shifts it does not have.
73
+ - **`resource_dependent_assignments(data)`**: `{task_id: [assignment, ...]}`, each TASKRSRC row as P6 schedules it on a resource dependent activity: `rsrc_id`; `clndr_id`, the resource's calendar (None for a role, or a resource the file does not carry, which then works the activity's calendar); `remaining_hours`, remaining units over remaining units per hour; `lag_hours`, the remaining lag, or the planned lag when that is blank; and `driving`, False only where Drive Activity Dates is off.
74
+
75
+ ### Testing
76
+
77
+ - `tests/test_p6_rules_2026_10_13.py` has 14 tests, all synthetic. Eleven fail on v0.3.0; the other three pin what must not move (a calendar with its week, the Monday to Friday count, and the shift-hours listing). CI runs the file under pytest and directly. `pytest tests/` is 130 tests across 13 files: 129 pass and the engine-parity test skips, and all 130 pass with `cpp-cpm-engine` v2.10.1's `python_reference` directory on `sys.path`.
78
+
79
+ ---
80
+
81
+ ## v0.3.0 — 2026-10-10
82
+
83
+ A calendar's hours per day are the hours its shifts work when every working day of its week works the same number of hours and `day_hr_cnt` says otherwise. That is how P6 schedules, measured on Primavera P6 Professional 23.12.1's own F9.
84
+
85
+ **Why a minor release.** Before 1.0 this project moves the minor number for a release that changes results a caller already reads. `get_calendar_map` can now give a calendar a different `hours_per_day`, and every hours-to-days conversion reads it: `duration_hours_to_days`, the float `generate_summary` reports, and anything a caller computes from the map. Nothing moves on a calendar whose shifts work the hours `day_hr_cnt` declares, or whose working days work different hours.
86
+
87
+ ### Changed
88
+
89
+ - **Hours per day come from the shifts when `day_hr_cnt` disagrees with all of them.** P6 schedules a duration in hours against the calendar's shifts; `day_hr_cnt` ("Hours per Day") only converts hours to days for display. Scheduled with F9 on 10-Oct-2026, a Monday-Saturday 07:00-17:00 calendar, ten hours a day, declaring `day_hr_cnt` 8, ran a 40 h activity over four days. `get_calendar_map` divided by 8 and gave five. It now reads the hours of each working day from the calendar's DaysOfWeek block, in either P6 slot order and with a 00:00 finish read as the end of the day, and when every working day works the same H and H is not `day_hr_cnt`, sets `hours_per_day` to H. A week whose working days work different hours keeps `day_hr_cnt`, since no one figure is right for it; a calendar with no DaysOfWeek block, or one of the older bare-day encodings, which carry no hours, keeps it too.
90
+
91
+ The rule is the one `cpp-cpm-engine` v2.9.54's `parseXER` applies, and CPP's internal parser since the same day. It was found comparing the engine with P6's F9 of real updates, where such a calendar put unfinished work days off P6, and measured on a synthetic probe project in P6.
92
+
93
+ ### Added
94
+
95
+ - **`declared_hours_per_day`** on a calendar whose `hours_per_day` was taken from its shifts: the file's `day_hr_cnt`, as it was.
96
+ - **`calendar_shift_hours(cal_map)`** lists those calendars: `clndr_id`, `clndr_name`, `hours_per_day` and `declared_hours_per_day`. Empty when none.
97
+ - **`CalendarShiftHoursWarning`**, a `UserWarning` subclass `get_calendar_map` raises once per such calendar, naming it and both figures.
98
+
99
+ ### Testing
100
+
101
+ - `tests/test_calendar_shift_hours_2026_10_10.py` has 7 tests, all synthetic: the ten-hour week declared at eight, 40 hours converting to four days, finish-first slots, a calendar whose shifts match `day_hr_cnt`, a week of unequal days, a midnight finish, and calendars with no hours to read. The three that pin the change fail on v0.2.1, and two more fail there because the function they call is new. CI runs the file under pytest and directly. `pytest tests/` is 116 tests across 12 files: 115 pass and the engine-parity test skips, and all 116 pass with `cpp-cpm-engine` v2.9.54's `python_reference` directory on `sys.path`.
102
+
103
+ ---
104
+
105
+ ## v0.2.1 — 2026-09-29
106
+
107
+ The bundled `ValidationReport` serialises with the summary CPP's internal version carries, so a caller can read a report's worst severity from it. Nothing a v0.2.0 caller already reads moves: the change adds a key, a property and an optional argument, and changes no existing value.
108
+
109
+ **Why a patch.** Before 1.0 this project moves the minor number for a release that changes results a caller already reads, as v0.2.0 did with the list at the top of its notes, and the patch number for a release that only adds. This one only adds, so it is v0.2.1. Code written against v0.2.0 runs unchanged and gets the same values.
110
+
111
+ ### Added
112
+
113
+ - **`ValidationReport.to_dict()` carries a `summary` block**: `block`, `warn`, `info` and `pass` (the number of findings at each severity), `total`, and `worst_severity`, the worst severity present, ranked BLOCK > WARN > INFO > PASS, and PASS for a report with no findings. These are the keys and values of CPP's internal report, in its order. `cpp-critical-path-validator` reads `summary['worst_severity']` from its embedded DCMA-14 report, and with the bundled subset it found no summary to read.
114
+ - **`ValidationReport.worst_severity`**, the property the summary reads, and **`count()` with no argument**, which returns the number of all findings; `count(severity)` is unchanged. `count`, `worst_severity`, the summary block and the severity ranking are ported from the internal module and are AST-identical to it without docstrings.
115
+
116
+ What still differs from the internal report is kept so that no existing output moves: `to_dict()` also carries `counts`, as before, and lists findings in the order they were added, where the internal report sorts them worst first. The module docstring says so. `xer_parser.py` and `config_profiles.py` are unchanged.
117
+
118
+ ### Testing
119
+
120
+ - `tests/test_report_summary_2026_09_29.py` has 9 tests, all synthetic: the summary's keys, order and values, the severity ranking, the empty report, `count()` with no argument, the unchanged `counts` key and finding order, a JSON round trip, and `validate_schedule` on a file with no logic reporting BLOCK as its worst severity. All 9 fail against v0.2.0's `validation.py`, and each of 7 mutants of the port is killed. CI runs the file under pytest and directly. `pytest tests/` is 109 tests across 11 files: 108 pass and the engine-parity test skips, and all 109 pass with `cpp-cpm-engine`'s `python_reference` directory on `sys.path`.
121
+
122
+ ---
123
+
124
+ ## v0.2.0 — 2026-09-29
125
+
126
+ The first release since v0.1.0. Calendar decoding, working-day arithmetic, the XER writer and the schedule summary are brought up to CPP's internal parser. A blank activity calendar id now resolves to the project calendar. `validate_schedule` and `aace_31r_compliance` run from a plain clone. The README, `SECURITY.md` and the table reference say what the code does.
127
+
128
+ **Output changes a caller may notice.** None of the public function signatures changed, but several results did, each from wrong to right:
129
+
130
+ - Finding `check_id`s are `XER-*`, no longer `AACE-31R-03-*` (see the last entries under Changed).
131
+ - Working-day counts and steps move on a finish-first calendar, on a worked exception day, for datetimes whose clock times differ, for half-day values, and for a zero-day step from a non-working day (see Fixed).
132
+ - `generate_summary`: `loe_count` no longer counts WBS summary rows, `critical_activities` is the complete list, and `critical_path` has no `note` key.
133
+ - `validate_schedule` raises the new activity-calendar findings (see Added).
134
+
135
+ ### Added
136
+
137
+ - **A blank activity calendar id is read as the project calendar.** MPXJ, converting an MS Project file, writes `TASK.clndr_id` empty for every task without a task-level calendar, and MS Project schedules such a task on the project calendar. A lookup on the blank id found no calendar. `generate_summary` then converted those activities' float at a flat 8 h/day, and `cpp-cpm-engine`, handed the rows without a project calendar, scheduled them on a continuous seven-day week. Three functions are new:
138
+ - `resolve_task_calendars(data)` gives the calendar every TASK row is scheduled on, through the chain `TASK.clndr_id` → `PROJECT.clndr_id` of the row's project → the `default_flag = 'Y'` calendar. It lists every row left with no usable calendar: blank with nothing to fall back on, or naming a calendar the file does not declare. A named calendar is never replaced.
139
+ - `with_resolved_calendars(tasks, resolution)` returns the rows carrying the resolved id. Filled rows are copies, and the parsed data is untouched.
140
+ - `calendar_resolution_block(resolution, keep=None)` builds the disclosure block, limited to the rows a consumer works on.
141
+ - **`validate_schedule` reports activity calendars.** It raises BLOCK `XER-TASK-CALENDAR-UNRESOLVED` for TASK rows with no usable calendar, and INFO `XER-TASK-CALENDAR-FALLBACK` when blank ids resolved. A file whose TASK rows carry no calendar, and which names no project or default calendar, now draws the BLOCK, and `aace_31r_compliance` scores it accordingly.
142
+ - `generate_summary` converts float on each activity's resolved calendar.
143
+ - `get_calendar_map` entries carry the row's `default_flag`.
144
+
145
+ ### Fixed
146
+
147
+ - **Calendar time slots decode in either field order.** P6 writes a slot as `(s|08:00|f|16:00)` or as `(f|16:00|s|08:00)`, and the order belongs to the calendar, not to the file or the P6 version: one genuine P6 24.12 export carries a start-first five-day calendar beside a finish-first six-day calendar. `parse_calendar_data` accepted only start-first at all three places it looks for a slot. A finish-first calendar therefore decoded to no working days, which `get_work_days_between`, `add_work_days` and `subtract_work_days` then counted as Mon-Fri, and every finish-first exception body was filed as a holiday. A finish-first seven-day calendar now counts seven days a week, and its worked exceptions are `special_workdays`. This changes output for any file carrying a finish-first calendar, from wrong to right. Three neighbouring defects in the exception decoder are fixed with it:
148
+ - The fallback classifier looked for a time slot immediately after the serial. P6 puts line markers and whitespace between the two, so on a continuous calendar every worked exception became a holiday. It now reads each serial's own segment, up to the next serial.
149
+ - Exception dates outside 1990-2050 were dropped. The window is now 1970-2099.
150
+ - A date that two parse paths classified differently landed in both `holidays` and `special_workdays`. A day carrying work hours is now a working day only.
151
+
152
+ `references/table-reference.md` documents the second slot order again (Time slots, and point 3 of the decoder notes). What this package still leaves out is unchanged and stated in the README's Scope section: there is no decode warning, and no corrupt-record screen. `tests/test_calendar_slot_order_2026_09_29.py` (14 tests, synthetic calendars) fails 10 of its 14 against the previous code; the other four pin behaviour that must not move.
153
+ - **Working-day arithmetic honours worked exception days and reads dates on their calendar day.** Five defects in `get_work_days_between`, `add_work_days` and `subtract_work_days`, each an output change:
154
+ - `parse_calendar_data` files a worked Saturday under `special_workdays`, and all three helpers ignored it, so the day dropped out of every count and every step. They now honour it, and a holiday still wins when a date is listed as both.
155
+ - `get_work_days_between` stripped the clock time from string inputs only. A datetime whose time of day was earlier than the start's was never reached, so Friday 17:00 to Monday 08:00 counted Friday alone, and a `date` against a `datetime` raised `TypeError`. Both dates are now read on their calendar day: Friday to Monday counts both days whatever the times.
156
+ - A span of more than 100 years (a sentinel finish such as 9999-12-31) now returns `None`, as an unreadable date does, instead of walking millions of days to a plausible-looking count.
157
+ - Fractional working days rounded half-to-even (0.5 to 0, 2.5 to 2). They now round half-up (0.5 to 1, 2.5 to 3), as `cpp-cpm-engine` does.
158
+ - `add_work_days(d, 0)` returned `d` unchanged. A non-working anchor now snaps forward to the next working day, and `subtract_work_days(d, 0)` snaps backward, as the engine does.
159
+
160
+ The docstrings of `add_work_days` and `subtract_work_days` said the opposite of what the functions do: they walk N gaps, so a task of D working days starting on ES finishes on `add_work_days(ES, D - 1)`, not `add_work_days(ES, D)`. The docstrings now say so. `get_work_days_between`, `_is_work_day`, `add_work_days` and `subtract_work_days`, and the two new helpers `_calendar_day` and `_round_half_up`, are ported from the internal parser and are AST-identical to it without docstrings and comments. The internal parser's `work_day_delta` is still not included (README, Scope). `tests/test_workday_arithmetic_2026_09_29.py` has 30 tests. The previous code cannot import `_round_half_up`; with that one name shimmed to the old rounding, 18 of the 29 tests that run without the engine fail against it. The engine-parity test runs when `cpp-cpm-engine`'s `python_reference` directory is on `sys.path` and skips otherwise.
161
+ - **`generate_xer` can no longer write a row that reads back shifted or split.** XER is tab-delimited and newline-terminated, and the writer emitted a value containing a tab or a line break as it stood: a `task_name` of `Pour<TAB>slab` read back as `task_name` `Pour` with `status_code` `slab`, and a line break split the row in two. Every emitted token (ERMHDR parts, table names, field names, cell values) now has each run of tab, CR and LF collapsed to one space by the new `_sanitize_cell`. Genuine P6 data never carries those characters, so this is a no-op on real exports; it matters for data built in code.
162
+ - **`parse_xer` keeps cells beyond the header.** A `%R` row with more cells than its `%F` line lost the extra cells without a trace. They are now kept on the record under `__extra_<column>` keys (for example `__extra_8`), as strings. Only a malformed or hand-edited row has them, and `generate_xer` writes only the declared fields.
163
+ - **`TABLE_ORDER` names the resource-code assignment table as P6 writes it.** It spelled the table `RSRCCAT`, a header no measured export writes (`references/table-reference.md` already said `RSRCRCAT`), so an `RSRCRCAT` table fell to the end of a generated file. It is now written in its slot between `RCATVAL` and `MEMOTYPE`. A phantom `RISKTYPES` entry, which duplicated `RISKTYPE` and matches no P6 table, is removed. The comment above the constant now says what the order is: a generation order P6 imports cleanly, not a canonical export order, which does not exist.
164
+ - **The summary counts LOE and WBS summary rows separately, and lists every critical activity.** Two output changes for callers of `generate_summary`: `schedule_metrics['loe_count']` now counts `TT_LOE` rows only (it counted WBS summary rows as LOE, and `print_summary` labelled the total "LOE"), and `critical_path['critical_activities']` is the complete list, where it stopped at 50 and carried a `note` key saying so; the `note` key is gone. New keys: `wbs_count`, and a work-only completion (`work_task_count`, `complete_work`, `percent_complete_work`) beside `percent_complete`, which counts milestones in both numerator and denominator and so overstates progress on a milestone-heavy schedule. `print_summary` prints both figures, names the WBS summary rows, and lists every field-count issue rather than the first ten.
165
+
166
+ These four fixes are ported from the internal parser. `generate_xer`, `_write_table`, `_sanitize_cell` and `TABLE_ORDER` are AST-identical to it without docstrings and comments. `parse_xer`, `generate_summary` and `print_summary` differ from it only where the README's Scope section says they do (no date-field gate, no calendar-decode warnings), and by a data-date check that needs a module this repository does not ship. `tests/test_writer_and_summary_2026_09_29.py` (14 tests, synthetic) fails 13 of its 14 against the previous code.
167
+ - **`validate_schedule` and `aace_31r_compliance` now run from a plain clone**, which is what the README has always said they do. Two defects stopped them. First, the optional-import stanza pulled `audit_trail` (which does not ship in this repository) in the same `try` block as `validation` and `config_profiles` (which do), so one missing module discarded the two that were present, and both functions raised `RuntimeError` with their dependencies sitting next to them in `scripts/`. The two import groups are now separate. Second, the bundled `ValidationReport` subset carried `counts()` but not `count(severity)`, which `aace_31r_compliance` calls to score a schedule; `count()` has been added to the subset, which leaves `xer_parser.py` unchanged for the repositories that vendor it. `tests/test_bundled_validation_runs.py` pins both halves and fails against the previous code.
168
+ - **`config_profiles.py` says how a profile is passed.** Its module docstring said a caller could clone a profile dict, change it and pass it directly, through the `profile` parameter on `dcma_14_assess`. `get_profile` looks a profile up by name, so a dict raises `TypeError`. The docstring now says a profile is passed by name, and how to add one: an entry in `_PROFILES` with every key the bundled profiles carry. It also said the threshold keys are documented in the dcma14.py header, which names none of them; they are commented in this file. Three of those comments used an old DCMA numbering (Missed Tasks #13, CPLI #14, BEI an "extension") and now follow the published one (#11, #13, #14). Docstring and comments only; nothing reads them. The copy `cpp-critical-path-validator` bundles gets the same text, so the two files are identical again.
169
+
170
+ ### Changed
171
+
172
+ - Seven `validate_schedule` finding messages are worded as sentences, where they used a dash (for example "No PROJECT records found in the XER. File is not a valid schedule."), matching CPP's internal parser. Check ids, severities, evidence and references are unchanged.
173
+ - **README positioning corrected.** The repository previously described itself as "the canonical Primavera P6 XER file parser and generator used by every Critical Path Partners forensic deliverable". It is not: CPP's deliverables are produced with a larger internal parser that adds validation not published here. The README now opens with a Scope section stating plainly what this package is (a standalone parser and generator, complete rather than a demonstration copy) and what it is not, naming the absent capabilities in capability terms: a date-field validation gate, a calendar-decode warning channel, and working-day arithmetic beyond `get_work_days_between` / `duration_hours_to_days`.
174
+ - `SECURITY.md` no longer claims this package "is used in production forensic delay analyses, EOT submissions, and expert-witness reports". It now describes what the package is and records that CPP's own deliverables run additional private validation.
175
+ - Table-reference claim corrected. `references/table-reference.md` documents 40 XER tables, 27 with a field-by-field list and 13 at table level. It previously read "the complete field-by-field reference for all 40+ XER tables".
176
+ - Feature table now shows the real `generate_xer_manifest(data, xer_path=None, **manifest_kwargs)` signature rather than a three-positional-argument form that raises `TypeError`, and records that the manifest needs the unbundled internal audit-trail module while the two validation entry points do not. `validate_schedule` and `aace_31r_compliance` appear in the table for the first time.
177
+ - **README field counts corrected to the measured ones.** The README's "XER generation rules" gave PROJECT 72, SCHEDOPTIONS 26, PROJWBS 27, TASK 62 and TASKPRED 12 for P6 24.12, one more than `TABLE_FIELD_COUNTS_BY_VERSION` holds for each, and an earlier unreleased edit published the disagreement as an open question. The 163 genuine exports at 23.10, 23.12 and 24.12 measured for `references/table-reference.md` settle it: 71, 25, 26, 61 and 11, as the constant already held, with `crt_path_num` the 61st TASK field at 24.12 as at 23.x. The README now gives those numbers, and the comment above the constant records the measurement in place of the `TODO(schema-truth)`. The v0.1.0 notes below carry the old figures and are left as released. The same section now also shows all nine ERMHDR fields, says the default encoding is UTF-8 (it said ASCII), calls `TABLE_ORDER` a generation order rather than an export order, and lists the `%E` marker and the cell-value rule.
178
+ - Removed the unverifiable marketing line "This closes the flagship gap in the commercial forensic-scheduling tooling market."
179
+ - The module docstring of `scripts/xer_parser.py` no longer calls the file the "Canonical Primavera P6 XER Engine" and the "single source of truth for all XER file operations across all skills".
180
+ - README badges: the hand-written `status: stable` badge, which could not go red, is replaced by the live GitHub Actions test badge and a version badge, as `cpp-critical-path-validator` did at its v0.2.0. `SECURITY.md` supports 0.2.x.
181
+ - Validation findings repointed to the correct AACE documents: file-validity and baseline-quality checks now cite AACE 29R-03 §2.1; profile-range checks (WBS depth, activity count) cite the CPP profile with AACE 38R-06 §3.5 (Planning Basis); missing-logic cites AACE 29R-03 §2.1.B.5 / DCMA 14-Point #1. Citations to AACE 31R-03 (a cost-estimate RP, the wrong document for these checks) and 53R-06 pinpoints (that RP has no numbered sections) were removed.
182
+ - Finding `check_id` values renamed from `AACE-31R-03-*` to `XER-*` (`XER-PROJECT-MISSING`, `XER-CALENDAR-MISSING`, `XER-WBS-DEPTH-LOW`, `XER-WBS-DEPTH-HIGH`, `XER-ACTIVITY-COUNT-LOW`, `XER-ACTIVITY-COUNT-HIGH`, `XER-NO-TASKPRED`).
183
+
184
+ ### Testing
185
+
186
+ - `pytest tests/` is 100 tests across 10 files: the existing parser, half-step and citation-guard files, `test_bundled_validation_runs.py`, three activity-calendar files (`test_task_calendar_resolution_2026_09_18.py`, `test_calendar_resolution_block_2026_09_19.py`, `test_calendar_map_default_flag_2026_09_19.py`), and three files for the ports above (`test_calendar_slot_order_2026_09_29.py`, `test_workday_arithmetic_2026_09_29.py`, `test_writer_and_summary_2026_09_29.py`). All fixtures are synthetic. 99 pass and one skips: the engine-parity test, which runs and passes when `cpp-cpm-engine`'s `python_reference` directory is on `sys.path`. The CI workflow's direct-invocation step runs `test_bundled_validation_runs.py` as well.
187
+
188
+ ### Engine compatibility
189
+
190
+ Tested against `cpp-cpm-engine` v2.9.49. With its `python_reference` directory on `sys.path`, the working-day parity test compares 6,490 `add_work_days` / `subtract_work_days` calls with the engine's, and all agree. The engine consumes the parse output and the parse output does not depend on the engine's math. Working-day parity has been measured against v2.9.49 only.
191
+
192
+ ---
193
+
194
+ ## v0.1.0 — 2026-05-10
195
+
196
+ Initial public release. Companion to [`cpp-cpm-engine`](https://github.com/danafitkowski/cpp-cpm-engine).
197
+
198
+ ### Features
199
+
200
+ - **Parse any Primavera P6 XER file** into a structured Python dict with all tables, fields, and records preserved.
201
+ - **Generate valid P6 24.12 XER files** from parsed or hand-built data. Field counts match P6 import expectations (PROJECT: 72, SCHEDOPTIONS: 26, PROJWBS: 27, TASK: 62, TASKPRED: 12).
202
+ - **Calendar decoding** including full work-week patterns, special workdays, and holidays. Handles the nested-paren and regex-fallback `clndr_data` formats that real-world P6 exports use.
203
+ - **Cross-reference maps** for WBS hierarchy (`build_wbs_map`), resource assignments (`build_resource_map`), predecessors and successors (`build_predecessor_map`), activity codes (`build_activity_code_map`), and user-defined fields (`build_udf_map`).
204
+ - **Schedule summary report** generation (`print_summary` / `generate_summary`) covering file info, project metrics, schedule metrics, critical path, relationships, calendars, resources, and data quality.
205
+ - **Half-step XER generator** (`compute_half_step_xer`) — implements the bifurcation procedure of AACE 29R-03 §2.3.D.2 ("Bifurcation: Creating a Progress-Only Half-Step Update", the RP's own name for half-stepping) as used by MIP 3.4 (Observational / Dynamic / Contemporaneous Split). Vendor half-step tools (SmartPM, Plannex) are equivalents of this procedure. Isolates progress impact from logic-revision impact across consecutive schedule updates.
206
+ - **BOM-aware encoding detection** (UTF-8 BOM / UTF-16 LE/BE BOM) — handles real-world P6 exports without manual encoding fiddling.
207
+ - **Schedule integrity manifest** (`generate_xer_manifest`) — SHA-256 source hash, parse metadata, schedule structure scoring via `aace_31r_compliance` (legacy name; the underlying checks cite AACE 29R-03, AACE 38R-06, and DCMA 14-Point). Requires the bundled `validation.py` + `config_profiles.py` stubs (included).
208
+ - **UDF type classification** (`get_udf_types`) — distinguishes text / numeric / date / start-date / finish-date UDFs.
209
+ - **Schema drift detection** (`schema_diff`) — compares two parsed XERs for added / removed / changed tables and fields.
210
+ - **Calendar exception classification** — separates `special_workdays` (Saturday work, etc.) from `holidays` so MIP 3.6 and TIA workflows can treat them correctly.
211
+ - **Unified validation report** (`validate_schedule`) producing the same `Finding` / `ValidationReport` types used throughout the CPP forensic suite.
212
+
213
+ ### Testing
214
+
215
+ - 2 test files cover the parser core (`test_xer_parser.py`) and the half-step generator (`test_half_step.py`).
216
+ - All test fixtures are fully synthetic — every XER referenced in the test suite is built in-memory at test time. No real client data ships with the repo.
217
+
218
+ ### Engine compatibility
219
+
220
+ Tested against `cpp-cpm-engine` v2.9.x (current as of 2026-05-16: v2.9.11+). The parse output is consumed by `cpm-engine.parseXER()` and is independent of the engine math; any `cpp-cpm-engine` 2.9.x version is compatible. Forward compatibility with future 2.x lines is intended but not guaranteed; the parse-output schema is the canonical interface contract.
221
+
222
+ ### Companion repos
223
+
224
+ - **[cpp-cpm-engine](https://github.com/danafitkowski/cpp-cpm-engine)** — The CPM engine that consumes data from this parser.
225
+ - **[cpp-critical-path-validator](https://github.com/danafitkowski/cpp-critical-path-validator)** — Critical path validation built on top of this parser.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dana Fitkowski / Critical Path Partners
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 @@
1
+ include CHANGELOG.md
@@ -0,0 +1,287 @@
1
+ Metadata-Version: 2.4
2
+ Name: cpp-xer-parser
3
+ Version: 0.6.1
4
+ Summary: Parse and write Primavera P6 XER files in pure Python: every table, calendars, cross-reference maps, half-step XERs and DCMA-14 profiles.
5
+ Author: Dana Fitkowski
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/danafitkowski/cpp-xer-parser
8
+ Project-URL: Changelog, https://github.com/danafitkowski/cpp-xer-parser/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/danafitkowski/cpp-xer-parser/issues
10
+ Keywords: primavera,p6,xer,schedule,cpm,construction,forensic,dcma
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Other Audience
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
23
+ Classifier: Topic :: Office/Business :: Scheduling
24
+ Classifier: Topic :: File Formats
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Dynamic: license-file
29
+
30
+ # cpp-xer-parser
31
+
32
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/danafitkowski/cpp-xer-parser/blob/main/LICENSE)
33
+ [![python: 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
34
+ [![tests](https://github.com/danafitkowski/cpp-xer-parser/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/danafitkowski/cpp-xer-parser/actions/workflows/test.yml)
35
+ [![version: 0.6.1](https://img.shields.io/badge/version-0.6.1-blue.svg)](https://github.com/danafitkowski/cpp-xer-parser/blob/main/CHANGELOG.md)
36
+
37
+ A standalone parser and generator for Primavera P6 XER files. Pure Python 3.10+, MIT-licensed, no third-party runtime dependencies.
38
+
39
+ Maintained by [Critical Path Partners](https://criticalpathpartners.ca) — a forensic-scheduling consultancy.
40
+
41
+ Companion to [`cpp-cpm-engine`](https://github.com/danafitkowski/cpp-cpm-engine).
42
+
43
+ ---
44
+
45
+ ## Scope: what this is, and what it is not
46
+
47
+ **What this is.** A complete XER reader and writer. It parses every table in an export into structured Python data, builds the cross-reference maps (WBS, resources, logic, activity codes, user-defined fields), decodes calendars, and writes XER files back out in P6 24.12 table order. Every function in the feature table below is implemented in this repository. None of them is a shim that calls out to something private. Vendor it, fork it, or import it.
48
+
49
+ **What this is not.** It is not the whole toolchain Critical Path Partners runs. CPP's forensic deliverables are produced with additional validation that is kept private and is not part of this package. Named in capability terms, this package does not include:
50
+
51
+ - **A date-field validation gate.** Date fields are parsed as they appear. There is no pass that flags unparseable or implausible date values before downstream math consumes them.
52
+ - **A calendar-decode warning channel.** `parse_calendar_data` decodes the standard P6 encoding, with time slots in either of the two field orders P6 writes, and then falls through two legacy patterns. When all of them fail it returns an empty work week rather than raising or warning, and the working-day helpers then count that calendar as Mon-Fri. Code that needs that signal has to inspect the returned structure itself. A corrupt calendar record is not reported to the caller either: a record P6 cannot bind (finish-first time slots under an illegal `clndr_type`) is decoded here as the week it declares, while P6 schedules its activities on its default Standard calendar, which is what `cpp-cpm-engine`'s calendar decoder does too.
53
+ - **Working-day arithmetic beyond `get_work_days_between` and `duration_hours_to_days`.** The additional working-day delta helper in CPP's internal parser is not included here.
54
+
55
+ None of that makes this parser wrong. It makes it smaller. If you are comparing output from this package against a report CPP produced, the difference in validation surface is the thing to ask about, and the answer is in this section rather than in a footnote.
56
+
57
+ ---
58
+
59
+ ## Why this parser
60
+
61
+ The XER format is fragile. Field counts shift between P6 versions. Calendar data ships inside a nested-paren mini-language. BOMs come and go without warning. Real-world exports from large programs use formats that simple split-on-tab parsers silently mangle.
62
+
63
+ It comes out of forensic schedule analysis, where a silently mangled field becomes a wrong number in a report that someone has to defend. The design goals follow from that: fields round-trip through parse and generate, the calendar encodings real exports actually use are decoded rather than guessed at, and generated files carry the field counts P6 expects on import. `tests/` is what backs those goals. Read it before relying on them.
64
+
65
+ ---
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install cpp-xer-parser
71
+ ```
72
+
73
+ Installed, the modules are the package `cpp_xer_parser`. The command `cpp-xer-parser` runs the same command line as `python scripts/xer_parser.py`.
74
+
75
+ Or use a clone with no install: no external dependencies, just put `scripts/` on your `sys.path`.
76
+
77
+ ```bash
78
+ git clone https://github.com/danafitkowski/cpp-xer-parser
79
+ cd cpp-xer-parser
80
+ ```
81
+
82
+ The parser is pure Python 3.10+ with no third-party runtime dependencies.
83
+
84
+ ---
85
+
86
+ ## Quick start
87
+
88
+ ```python
89
+ from cpp_xer_parser import parse_xer, print_summary, get_table, build_wbs_map
90
+ # From a clone instead: sys.path.insert(0, 'scripts'), then
91
+ # from xer_parser import parse_xer, print_summary, get_table, build_wbs_map
92
+
93
+ # Parse
94
+ data = parse_xer('path/to/your.xer')
95
+
96
+ # Print a one-shot summary
97
+ print_summary(data)
98
+
99
+ # Drill into tables
100
+ tasks = get_table(data, 'TASK')
101
+ print(f'{len(tasks)} activities')
102
+
103
+ # Cross-reference maps
104
+ wbs = build_wbs_map(data)
105
+ for t in tasks[:3]:
106
+ print(t['task_code'], '→', wbs[t['wbs_id']]['_full_path'])
107
+ ```
108
+
109
+ ---
110
+
111
+ ## What it does
112
+
113
+ | Feature | Function |
114
+ |--------------------------------------|-----------------------------------------------------------------------|
115
+ | Parse XER to structured dict | `parse_xer(path)` |
116
+ | Generate XER from dict | `generate_xer(data, path, p6_version='24.12', currency='CAD', ...)` |
117
+ | Table accessor | `get_table(data, 'TASK')` |
118
+ | Calendar map (with holidays) | `get_calendar_map(data)` |
119
+ | Activity calendars, blank id = project calendar | `resolve_task_calendars(data)`, `with_resolved_calendars(tasks, resolution)` |
120
+ | Calendar-resolution disclosure block | `calendar_resolution_block(resolution, keep=None)` |
121
+ | Calendars converted on their shifts | `calendar_shift_hours(cal_map)` |
122
+ | Corrupt calendar records, P6's fallback | `calendar_corrupt_fallbacks(cal_map)` |
123
+ | WBS hierarchy with full paths | `build_wbs_map(data)` |
124
+ | Resource assignments per task | `build_resource_map(data)` |
125
+ | Assignments as P6 schedules resource dependent work | `resource_dependent_assignments(data)` |
126
+ | Predecessor / successor maps | `build_predecessor_map(data)` |
127
+ | Activity code map | `build_activity_code_map(data)` |
128
+ | User-defined field map | `build_udf_map(data)` |
129
+ | Work-day count between two dates | `get_work_days_between(start, end, calendar)` |
130
+ | Duration hours-to-days conversion | `duration_hours_to_days(hours, calendar)` |
131
+ | Structural validation report | `validate_schedule(data, profile='commercial')` |
132
+ | Schedule structure score | `aace_31r_compliance(data, profile='commercial')` |
133
+ | Schedule integrity manifest † | `generate_xer_manifest(data, xer_path=None, **manifest_kwargs)` |
134
+ | Schema drift between two XERs | `schema_diff(data_a, data_b)` |
135
+ | Half-step XER generator (SmartPM-equivalent) | `compute_half_step_xer(base_path, updated_path, output_path)` |
136
+
137
+ † `validate_schedule` and `aace_31r_compliance` run from a plain clone: they use the bundled `scripts/validation.py` and `scripts/config_profiles.py`. `generate_xer_manifest` also needs CPP's internal audit-trail module, which is **not** bundled here. Without it the call raises `RuntimeError` rather than returning a manifest with missing provenance. Everything else in the table needs nothing but the standard library.
138
+
139
+ ---
140
+
141
+ ## The data date P6 schedules from
142
+
143
+ `parse_xer` reads `PROJECT.last_recalc_date` as P6 schedules from it, and lists every project it changed in `data['data_date_filled']`, so a report can say the date was not the file's own.
144
+
145
+ - A blank data date is the project's planned start: P6 schedules "from the project data date, using the project start date as the initial data date". The entry reads `{'proj_id', 'last_recalc_date', 'filled_from': 'plan_start_date'}`. A blank with no planned start stays blank.
146
+ - A filed data date on an earlier day than the planned start is lifted to the planned start, as P6 lifts it when it opens the project (data date 28 August, planned start 1 September: P6 shows and schedules from 1 September). The entry also carries the filed date as `filed_data_date`. A planned start later the same day is left as filed.
147
+
148
+ ---
149
+
150
+ ## Blank activity calendar ids
151
+
152
+ A blank `TASK.clndr_id` means the project calendar, not "no calendar". MPXJ writes it blank for every task of a converted MS Project file that has no task-level calendar. Looked up as it stands, `cal_map.get(task['clndr_id'])` returns `None`, and whatever runs next falls to its last resort: 8 h/day in `duration_hours_to_days`, without a word, and in `cpp-cpm-engine` a continuous seven-day week, with an ALERT, unless the caller hands it the project calendar. Take the TASK rows through the resolver before looking a calendar up:
153
+
154
+ ```python
155
+ from cpp_xer_parser.xer_parser import (
156
+ get_table, get_calendar_map, duration_hours_to_days,
157
+ resolve_task_calendars, with_resolved_calendars)
158
+
159
+ resolution = resolve_task_calendars(data)
160
+ tasks = with_resolved_calendars(get_table(data, 'TASK'), resolution)
161
+ cal_map = get_calendar_map(data)
162
+ for t in tasks:
163
+ float_days = duration_hours_to_days(t.get('total_float_hr_cnt'),
164
+ cal_map.get(t.get('clndr_id', '')))
165
+ ```
166
+
167
+ - `resolve_task_calendars` applies the chain `TASK.clndr_id` → `PROJECT.clndr_id` for the row's `proj_id` → the calendar flagged `default_flag = 'Y'`.
168
+ - `with_resolved_calendars` fills only blank ids. A filled row is a copy and every other row is passed through as it is, so the parsed data stays as received.
169
+ - `resolution['unresolved']` lists every row left with no usable calendar: a blank id with nothing to fall back on, or an id the CALENDAR table does not declare, which is reported and never replaced.
170
+ - `validate_schedule` raises a BLOCK `XER-TASK-CALENDAR-UNRESOLVED` for those rows, and an INFO `XER-TASK-CALENDAR-FALLBACK` when blank ids resolved. `generate_summary` converts float on the resolved calendar.
171
+ - A consumer that works on only some of the rows (one project, no LOE or WBS summaries) builds what it discloses with `calendar_resolution_block(resolution, keep=...)`.
172
+ - `get_calendar_map` entries carry the row's `default_flag`.
173
+ - A calendar's `hours_per_day` is the hours its shifts work when every working day of its week works the same number of hours and `day_hr_cnt` says otherwise. P6 schedules a duration against the shifts; `day_hr_cnt` ("Hours per Day") only converts hours to days for display. A Monday-Saturday 07:00-17:00 calendar declaring 8 ran a 40 h activity over four days on P6's own F9, and `duration_hours_to_days` now gives 4. The file's figure is kept as `declared_hours_per_day`, `calendar_shift_hours(cal_map)` lists every such calendar, and a `CalendarShiftHoursWarning` names each. A week whose working days work different hours keeps `day_hr_cnt`.
174
+ - An empty calendar record (no `clndr_data`) is P6's internal Standard: Monday to Friday, 08:00-16:00, eight hours a day, no holidays, whatever `day_hr_cnt` declares. Records declaring 10, 6 and 24 hours all ran the same 08:00-16:00 days on P6's own F9. The entry carries `empty_record: True` and keeps the declared figure as `declared_hours_per_day`; `calendar_shift_hours` does not list it, since it has no shifts.
175
+ - A corrupt calendar record is P6's internal Standard too: Monday to Friday, eight hours a day, no holidays. A record is corrupt when its week's shift slots are written finish-first, `(f|HH:MM|s|HH:MM)`, and its `clndr_type` is not a legal P6 token (`CA_Base`, `CA_Rsrc`, `CA_Project`); the one seen in real exports says `CT_Project`. P6 cannot bind such a record and schedules its activities on that Standard, as its own stored dates show. The entry carries `p6_fallback: True` and the `calendar-corrupt-p6-fallback` ALERT under `corrupt_record_alert`; `calendar_corrupt_fallbacks(cal_map)` lists the alerts, and a `CalendarCorruptRecordWarning` names each calendar. Finish-first slots with a legal type are an ordinary export variant and decode as declared. The screen is `cpp-cpm-engine`'s own `decode_calendar_record`, copied into the parser.
176
+
177
+ ---
178
+
179
+ ## Half-step XER (vendor-equivalent: SmartPM/Plannex)
180
+
181
+ The most analytically interesting feature in the repo. The `compute_half_step_xer` function implements the bifurcation procedure of AACE 29R-03 §2.3.D.2 ("Bifurcation: Creating a Progress-Only Half-Step Update" — the RP itself names the procedure "half-stepping or two-stepping"), which is how MIP 3.4 (Observational / Dynamic / Contemporaneous Split) splits each update. Vendor half-step tools (SmartPM, Plannex) are equivalents of this procedure. It takes a period-start schedule plus the next period's update, and produces a schedule that has **only the progress fields** copied from the updated schedule onto the base structure.
182
+
183
+ The forensic value: anything that moves in the half-step moved because work didn't happen as planned. Anything that moves between the half-step and the full update moved because the contractor revised logic or scope.
184
+
185
+ ```python
186
+ from cpp_xer_parser import compute_half_step_xer
187
+
188
+ result = compute_half_step_xer(
189
+ base_xer_path='period_start.xer',
190
+ updated_xer_path='period_end.xer',
191
+ output_xer_path='half_step.xer',
192
+ )
193
+
194
+ print(f"Matched: {result['matched_count']}")
195
+ print(f"Progressed: {result['progressed_count']}")
196
+ print(f"Activities added in updated (NOT copied): {result['unmatched_in_updated']}")
197
+ print(f"Activities removed in updated (preserved in output): {result['unmatched_in_base']}")
198
+ ```
199
+
200
+ `unmatched_in_updated` is forensically significant: these are activities the contractor added between period boundaries, and they belong to the logic-revision layer, not the progress layer.
201
+
202
+ ---
203
+
204
+ ## XER generation rules (P6 24.12)
205
+
206
+ The generator emits P6-importable XER files following these rules:
207
+
208
+ 1. **Table order**: CURRTYPE, FINTMPL, OBS, PROJECT, CALENDAR, SCHEDOPTIONS, PROJWBS, TASK, TASKPRED, then remaining tables. See the `TABLE_ORDER` constant. This is a generation order that P6 imports cleanly, not an order real exports obey: the 166 exports measured for `references/table-reference.md` produced 38 distinct `%T` sequences (see its Export order section). When parsing, never assume an order.
209
+ 2. **ERMHDR line**: nine tab-separated fields, `ERMHDR\t24.12\t<export_date>\t<export_scope>\t<user>\t<user_full_name>\t<database>\tProject Management\t<currency>`. A header read from a parsed file is written back as it was read; otherwise it is built from the `generate_xer` keyword arguments.
210
+ 3. **Field counts**: every `%R` row must have exactly the same number of fields as the `%F` definition that precedes it. The generator enforces this.
211
+ 4. **Known field counts for P6 23.10, 23.12 and 24.12**, measured across the 163 genuine exports at those versions behind `references/table-reference.md`, and held in `TABLE_FIELD_COUNTS_BY_VERSION`:
212
+ - PROJECT: 71 fields
213
+ - SCHEDOPTIONS: 25 fields
214
+ - PROJWBS: 26 fields
215
+ - TASK: 61 fields (`crt_path_num` is the 61st, at 24.12 as at 23.x)
216
+ - TASKPRED: 11 fields
217
+
218
+ No export gave a different count for a table it carried, and the 50 genuine 24.12 exports match 23.12 on all five. Earlier versions of this README gave 72, 26, 27, 62 and 12 and called the difference from the constant an open question; the measurement settles it in the constant's favour. 19.12 is a different layout (PROJECT 82, TASK 66, TASKPRED 10). Counts are a sanity check, not a parsing contract: index fields by name from the `%F` line, never by position.
219
+ 5. **Encoding**: UTF-8 with CRLF line endings by default. Pass `encoding='cp1252'` for strict legacy P6 compatibility with non-ASCII text.
220
+ 6. **Calendar data**: when generating from scratch, copy the `clndr_data` field verbatim from a working reference XER. Re-encoding is brittle.
221
+ 7. **End-of-file marker**: the file ends with a `%E` line, as genuine P6 exports do. The parser skips it, so it never becomes a table and does not multiply on a round trip.
222
+ 8. **Cell values**: a tab, CR or LF inside a value would add a column or split a row, so each run of them is written as one space. Genuine P6 data never carries them.
223
+
224
+ See `references/table-reference.md` for the table reference: 40 XER tables, 27 of them with a field-by-field list, and 13 less common ones (documents, roles, shifts, resource codes, risk) described at table level rather than field level.
225
+
226
+ ---
227
+
228
+ ## Running the tests
229
+
230
+ ```bash
231
+ python tests/test_xer_parser.py # core parser + generator smoke test
232
+ python tests/test_half_step.py # half-step generator (SmartPM-equivalent)
233
+ python tests/test_bundled_validation_runs.py # bundled validation stubs bind on a plain clone
234
+ ```
235
+
236
+ Or with pytest:
237
+
238
+ ```bash
239
+ pip install pytest
240
+ pytest tests/
241
+ ```
242
+
243
+ `pytest tests/` runs 142 tests across 14 files. 140 pass and two skip: parity checks against `cpp-cpm-engine`, of `add_work_days` and `subtract_work_days` and of the corrupt-calendar screen, which run when that engine's `python_reference` directory is on `sys.path` (for example `PYTHONPATH=../cpp-cpm-engine/python_reference pytest tests/`), and then all 142 pass. CI runs the same suite on Linux, macOS and Windows against Python 3.10, 3.11 and 3.12.
244
+
245
+ All tests build their XER fixtures synthetically in memory; no real client XER files ship with the repo.
246
+
247
+ ---
248
+
249
+ ## Relationship to CPP's internal toolchain
250
+
251
+ Critical Path Partners keeps a larger internal parser that this package was cut from. What is published here is the parsing and generation core. What is not published is the extra validation surface listed under [Scope](#scope-what-this-is-and-what-it-is-not): the date-field gate, the calendar-decode warning channel, and the additional working-day arithmetic. Reports CPP issues are produced with the internal toolchain, not with this package on its own.
252
+
253
+ Two consequences worth being explicit about:
254
+
255
+ 1. **Nothing here is a stub.** The functions in the feature table are the working implementations, and the test suite exercises them. This is a smaller parser, not a demonstration copy.
256
+ 2. **If you are auditing a CPP deliverable**, this repository shows you the parsing and generation logic behind it, and the Scope section tells you what else ran that you cannot see here. Ask about the difference rather than inferring it.
257
+
258
+ `scripts/validation.py` and `scripts/config_profiles.py` are minimal standalone subsets of the same-named modules in the larger internal suite: enough for `validate_schedule` and `aace_31r_compliance` to run end-to-end from a plain clone. A report's `to_dict()` carries the internal version's `summary` block, whose `worst_severity` names the worst finding; it also keeps this subset's `counts` key, and lists findings in the order they were added rather than worst first. `generate_xer_manifest` additionally needs the internal audit-trail module, which is not bundled; see the note under the feature table.
259
+
260
+ ---
261
+
262
+ ## License
263
+
264
+ MIT — see [LICENSE](https://github.com/danafitkowski/cpp-xer-parser/blob/main/LICENSE).
265
+
266
+ You may use this parser in commercial forensic consulting, in academic research, in your own scheduling product, in court-filed expert reports. Just keep the copyright notice.
267
+
268
+ ---
269
+
270
+ ## Contributing
271
+
272
+ See [CONTRIBUTING.md](https://github.com/danafitkowski/cpp-xer-parser/blob/main/CONTRIBUTING.md). Bug reports and pull requests are welcome.
273
+
274
+ ---
275
+
276
+ ## Companion repositories
277
+
278
+ - **[cpp-cpm-engine](https://github.com/danafitkowski/cpp-cpm-engine)** — The forensically-defensible CPM engine.
279
+ - **[cpp-critical-path-validator](https://github.com/danafitkowski/cpp-critical-path-validator)** — Critical path validation, DCMA-14 assessment, and logic health review.
280
+
281
+ ---
282
+
283
+ ## Strategic note
284
+
285
+ Critical Path Partners is a forensic-scheduling consultancy. We open-source the foundational tooling because every academic, every solo forensic, every contractor's internal scheduler now has a reason to install CPP and a citation pathway. The math is a commodity; the workflow and discipline are not.
286
+
287
+ If you ship something built on this parser, we'd like to hear about it: [criticalpathpartners.ca](https://criticalpathpartners.ca).