cctally 1.92.2 → 1.93.0
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.
- package/CHANGELOG.md +43 -0
- package/bin/_cctally_cache.py +354 -0
- package/bin/_cctally_core.py +180 -3
- package/bin/_cctally_dashboard.py +71 -1
- package/bin/_cctally_dashboard_envelope.py +28 -2
- package/bin/_cctally_dashboard_share.py +75 -19
- package/bin/_cctally_dashboard_sources.py +12 -0
- package/bin/_cctally_db.py +89 -1
- package/bin/_cctally_doctor.py +31 -0
- package/bin/_cctally_forecast.py +4 -2
- package/bin/_cctally_journal.py +3482 -258
- package/bin/_cctally_journal_repair.py +123 -32
- package/bin/_cctally_milestone_history.py +4 -1
- package/bin/_cctally_project.py +8 -6
- package/bin/_cctally_quota.py +420 -20
- package/bin/_cctally_rederive.py +57 -23
- package/bin/_cctally_reporting.py +8 -6
- package/bin/_cctally_share.py +74 -37
- package/bin/_cctally_source_analytics.py +6 -8
- package/bin/_cctally_store.py +13 -2
- package/bin/_cctally_tui.py +53 -0
- package/bin/_lib_cache_coverage.py +547 -0
- package/bin/_lib_doctor.py +54 -2
- package/bin/_lib_journal.py +235 -95
- package/bin/_lib_journal_router.py +21 -0
- package/bin/_lib_segment_summary.py +374 -0
- package/bin/_lib_selector_state.py +959 -0
- package/bin/_lib_share.py +1073 -165
- package/bin/_lib_share_templates.py +35 -11
- package/bin/_lib_stats_wal.py +327 -0
- package/bin/_lib_view_models.py +2 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
- package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-DnWdv8um.css +0 -1
package/bin/_lib_share.py
CHANGED
|
@@ -7,12 +7,14 @@ Spec: docs/superpowers/specs/2026-05-08-shareable-reports-design.md
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import base64
|
|
11
|
+
import dataclasses
|
|
10
12
|
import hashlib
|
|
11
13
|
import json
|
|
12
14
|
import math
|
|
13
15
|
import re
|
|
14
|
-
from collections.abc import Callable, Mapping
|
|
15
|
-
from dataclasses import dataclass
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
16
18
|
from datetime import datetime
|
|
17
19
|
from typing import Literal
|
|
18
20
|
|
|
@@ -115,12 +117,23 @@ class PeriodSpec:
|
|
|
115
117
|
|
|
116
118
|
@dataclass(frozen=True)
|
|
117
119
|
class ChartPoint:
|
|
120
|
+
"""One chart datum.
|
|
121
|
+
|
|
122
|
+
`x_label_kind` is the AXIS DISCRIMINATOR. Privacy preparation rewrites the
|
|
123
|
+
`x_label` of a `"project"` axis and leaves a `"plain"` axis untouched; it
|
|
124
|
+
must never infer the axis from `x_label == project_label`, because the two
|
|
125
|
+
`sessions` builders deliberately break that equality (issue #503 F1).
|
|
126
|
+
A `"project"` axis composes its label from the resolved project display
|
|
127
|
+
name, prefixed by `x_label_prefix` (the cost rank) when one is present.
|
|
128
|
+
"""
|
|
118
129
|
x_label: str
|
|
119
130
|
x_value: float
|
|
120
131
|
y_value: float
|
|
121
132
|
project_label: str | None = None
|
|
122
133
|
series_key: str | None = None
|
|
123
134
|
project_identity: str | None = None
|
|
135
|
+
x_label_kind: Literal["plain", "project"] = "plain"
|
|
136
|
+
x_label_prefix: str | None = None
|
|
124
137
|
|
|
125
138
|
|
|
126
139
|
@dataclass(frozen=True)
|
|
@@ -235,8 +248,8 @@ class ComposeOptions:
|
|
|
235
248
|
theme: str # "light" | "dark"
|
|
236
249
|
format: str # "md" | "html" | "svg"
|
|
237
250
|
no_branding: bool
|
|
238
|
-
#
|
|
239
|
-
#
|
|
251
|
+
# `compose()` reads this to prepare every section itself (#503 S1).
|
|
252
|
+
# Sections must arrive RAW; callers must not anonymize upstream.
|
|
240
253
|
reveal_projects: bool
|
|
241
254
|
|
|
242
255
|
|
|
@@ -950,22 +963,411 @@ def _apply_anon_mapping(
|
|
|
950
963
|
) -> ShareSnapshot:
|
|
951
964
|
"""Return a new ShareSnapshot with project labels replaced everywhere.
|
|
952
965
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
966
|
+
Kept for backward compatibility as `_scrub`'s applier. It normalizes the
|
|
967
|
+
legacy all-string key shape and then delegates to the single project-field
|
|
968
|
+
walker `_apply_project_mapping`, so there is exactly one implementation of
|
|
969
|
+
"rewrite every typed project display field" rather than two that have to
|
|
970
|
+
agree by inspection — which is how the `x_label` fall-through survived.
|
|
956
971
|
"""
|
|
957
972
|
tagged_mapping: dict[_ProjectAnonKey, str] = {
|
|
958
973
|
("legacy", key) if isinstance(key, str) else key: value
|
|
959
974
|
for key, value in mapping.items()
|
|
960
975
|
}
|
|
976
|
+
return _apply_project_mapping(snap, tagged_mapping)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _scrub(snap: ShareSnapshot, *, reveal_projects: bool) -> ShareSnapshot:
|
|
980
|
+
"""Anonymize project labels unless reveal_projects is True.
|
|
981
|
+
|
|
982
|
+
When reveal_projects is True, returns the SAME instance (identity preserved
|
|
983
|
+
so callers can rely on `out is snap`). When False, returns a NEW snapshot
|
|
984
|
+
with ProjectCell labels and ChartPoint project/x labels rewritten via
|
|
985
|
+
`_build_anon_mapping`. If no project labels are present in the snapshot,
|
|
986
|
+
also returns the original instance.
|
|
987
|
+
"""
|
|
988
|
+
if reveal_projects:
|
|
989
|
+
return snap
|
|
990
|
+
project_costs = _collect_project_identity_costs(snap)
|
|
991
|
+
if not project_costs:
|
|
992
|
+
return snap
|
|
993
|
+
mapping = _build_anon_mapping(project_costs)
|
|
994
|
+
return _apply_anon_mapping(snap, mapping)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
# --- Preparation: the privacy contract (#503 S1) ---
|
|
998
|
+
#
|
|
999
|
+
# `_scrub` above is a hand-enumerated field walker: it visits three sites out
|
|
1000
|
+
# of a seventeen-field frozen dataclass graph and copies everything else
|
|
1001
|
+
# through untouched. A value leaks whenever a builder places it somewhere the
|
|
1002
|
+
# enumeration does not reach. Preparation replaces it as the path the entry
|
|
1003
|
+
# points use. `_scrub` stays public and identity-preserving for backward
|
|
1004
|
+
# compatibility; it is simply no longer how `render()` and `compose()` get
|
|
1005
|
+
# their anonymization.
|
|
1006
|
+
#
|
|
1007
|
+
# Preparation is stage 2 of the four-stage contract the entry points run:
|
|
1008
|
+
# inventory -> prepare -> render -> verify. It rewrites ONLY typed project
|
|
1009
|
+
# display fields, so a builder that puts a path into `title`, `notes` or
|
|
1010
|
+
# `totals` is caught by stage 4 and raises rather than being silently
|
|
1011
|
+
# corrected. That is intended: silent correction hides the builder defect.
|
|
1012
|
+
|
|
1013
|
+
ANON_UNKNOWN = "(unknown)"
|
|
1014
|
+
|
|
1015
|
+
_PREPARED_ATTR = "_share_prepared_provenance"
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
class SharePreparationError(Exception):
|
|
1019
|
+
"""Raised when a snapshot reaches an entry point already prepared.
|
|
1020
|
+
|
|
1021
|
+
A second preparation pass is not idempotent. On the legacy path — where
|
|
1022
|
+
`ProjectCell.identity` is None — the alias key of an already-aliased label
|
|
1023
|
+
becomes ``("legacy", "project-1")`` and a second pass RENUMBERS by
|
|
1024
|
+
re-ranking. In compose it is worse: two distinct raw projects that each
|
|
1025
|
+
mapped locally to ``project-1`` collapse into one legacy key before global
|
|
1026
|
+
ranking, merging two projects into a single alias.
|
|
1027
|
+
|
|
1028
|
+
An alias-shape assertion is deliberately NOT the discriminator here — a
|
|
1029
|
+
real project can legitimately be named ``project-1`` — so preparation
|
|
1030
|
+
stamps an explicit provenance marker instead.
|
|
1031
|
+
"""
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@dataclass(frozen=True)
|
|
1035
|
+
class _PreparedProvenance:
|
|
1036
|
+
"""What preparation did, recorded for the verification stage.
|
|
1037
|
+
|
|
1038
|
+
`originals` are the project display labels preparation consumed;
|
|
1039
|
+
`allowed` are the values it is permitted to have emitted in their place.
|
|
1040
|
+
Verification's provenance half checks the emitted values against
|
|
1041
|
+
`allowed` rather than searching the document for `originals`, because a
|
|
1042
|
+
project legitimately named `cctally` collides with the static branding
|
|
1043
|
+
string this module emits and a text search would fail a correctly
|
|
1044
|
+
anonymized artifact.
|
|
1045
|
+
"""
|
|
1046
|
+
reveal_projects: bool
|
|
1047
|
+
originals: frozenset[str]
|
|
1048
|
+
allowed: frozenset[str]
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _is_prepared(snap: ShareSnapshot) -> bool:
|
|
1052
|
+
"""True when `_prepare` produced this snapshot object."""
|
|
1053
|
+
return isinstance(getattr(snap, _PREPARED_ATTR, None), _PreparedProvenance)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _provenance_of(snap: ShareSnapshot) -> "_PreparedProvenance | None":
|
|
1057
|
+
value = getattr(snap, _PREPARED_ATTR, None)
|
|
1058
|
+
return value if isinstance(value, _PreparedProvenance) else None
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# --- Reveal-mode display labels ---
|
|
1062
|
+
|
|
1063
|
+
def _path_segments(path: str) -> list[str]:
|
|
1064
|
+
return [seg for seg in path.split("/") if seg]
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def disambiguate_basenames(paths: Sequence[str]) -> dict[int, str]:
|
|
1068
|
+
"""Return ``{index: displayed label}`` for a list of project paths.
|
|
1069
|
+
|
|
1070
|
+
INPUT CONTRACT: `paths` is one entry per DISTINCT project identity.
|
|
1071
|
+
Callers holding several rows of the same project must deduplicate first
|
|
1072
|
+
and fan the returned label back out themselves. This function is total —
|
|
1073
|
+
given two identical paths it cannot tell them apart, so it appends a
|
|
1074
|
+
stable ordinal to keep the mapping injective — and that ordinal is wrong
|
|
1075
|
+
output for duplicates of one project: it displays one project under
|
|
1076
|
+
several names, and on the legacy path (where `ProjectCell.identity` is
|
|
1077
|
+
None and the alias key is the label) it also splits that project's cost
|
|
1078
|
+
across several alias slots. Every in-tree caller honors the contract:
|
|
1079
|
+
`_resolved_project_labels` and `_merged_project_mapping` pass one entry
|
|
1080
|
+
per `_ProjectAnonKey`, and `_cctally_share._session_disambiguate_labels`
|
|
1081
|
+
deduplicates by path.
|
|
1082
|
+
|
|
1083
|
+
Reveal mode shows a project's basename, never its full path. Bare
|
|
1084
|
+
``os.path.basename`` is NOT sufficient and using it would reintroduce a
|
|
1085
|
+
defect the CLI already solved: two ``app`` projects under different
|
|
1086
|
+
parents collapse into one indistinguishable label, and after
|
|
1087
|
+
anonymization into ONE ``project-N`` alias — losing both privacy
|
|
1088
|
+
uniqueness and chart-rank meaning. See the comment at
|
|
1089
|
+
``bin/_cctally_share.py`` above ``_project_disambiguate_labels``'s call
|
|
1090
|
+
site, which records that reasoning.
|
|
1091
|
+
|
|
1092
|
+
The algorithm is deterministic: basename; on collision a parent-directory
|
|
1093
|
+
suffix ``" (parent)"``; on a repeated parent, progressively more path
|
|
1094
|
+
segments; and a stable ordinal as the last resort when the paths
|
|
1095
|
+
themselves are indistinguishable. A label with no separator (already a
|
|
1096
|
+
basename, or an already-disambiguated ``app (work)`` from the CLI) is its
|
|
1097
|
+
own basename and passes through untouched.
|
|
1098
|
+
|
|
1099
|
+
``(unknown)`` is never suffixed: ``_build_anon_mapping`` protects only the
|
|
1100
|
+
exact literal, so a suffixed ``(unknown) (/)`` would be numbered like an
|
|
1101
|
+
ordinary project and lose the sentinel's meaning.
|
|
1102
|
+
"""
|
|
1103
|
+
segs = [_path_segments(p or "") for p in paths]
|
|
1104
|
+
bases: list[str] = []
|
|
1105
|
+
for i, p in enumerate(paths):
|
|
1106
|
+
bases.append(segs[i][-1] if segs[i] else ((p or "") or ANON_UNKNOWN))
|
|
1107
|
+
labels: dict[int, str] = dict(enumerate(bases))
|
|
1108
|
+
max_depth = max((len(s) for s in segs), default=1)
|
|
1109
|
+
depth = 1
|
|
1110
|
+
while True:
|
|
1111
|
+
groups: dict[str, list[int]] = {}
|
|
1112
|
+
for idx, lab in labels.items():
|
|
1113
|
+
groups.setdefault(lab, []).append(idx)
|
|
1114
|
+
collided = [
|
|
1115
|
+
idxs for lab, idxs in groups.items()
|
|
1116
|
+
if len(idxs) > 1 and lab != ANON_UNKNOWN
|
|
1117
|
+
]
|
|
1118
|
+
if not collided:
|
|
1119
|
+
return labels
|
|
1120
|
+
if depth > max_depth:
|
|
1121
|
+
# Indistinguishable inputs (identical paths, or paths that differ
|
|
1122
|
+
# only past every segment we can show). Stay total and stay
|
|
1123
|
+
# deterministic rather than emitting duplicates.
|
|
1124
|
+
for idxs in collided:
|
|
1125
|
+
for rank, idx in enumerate(sorted(idxs), 1):
|
|
1126
|
+
labels[idx] = f"{labels[idx]} ({rank})"
|
|
1127
|
+
return labels
|
|
1128
|
+
for idxs in collided:
|
|
1129
|
+
for idx in idxs:
|
|
1130
|
+
tail = segs[idx][max(0, len(segs[idx]) - 1 - depth):len(segs[idx]) - 1]
|
|
1131
|
+
# `"/"` mirrors the CLI's `os.path.basename(os.path.dirname(p))
|
|
1132
|
+
# or "/"` fallback for a path with no parent segment.
|
|
1133
|
+
qualifier = "/".join(tail) or "/"
|
|
1134
|
+
labels[idx] = f"{bases[idx]} ({qualifier})"
|
|
1135
|
+
depth += 1
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
# --- Inventory ---
|
|
1139
|
+
|
|
1140
|
+
@dataclass(frozen=True)
|
|
1141
|
+
class SensitiveInventory:
|
|
1142
|
+
"""What verification knows about one render's project provenance.
|
|
1143
|
+
|
|
1144
|
+
`project_labels` is what the raw snapshot carried at its typed project
|
|
1145
|
+
display sites, `prepared_labels` what preparation emitted there, and
|
|
1146
|
+
`allowed_labels` what preparation was permitted to emit. All three come
|
|
1147
|
+
from `_map_project_display`, the SINGLE enumeration of those sites.
|
|
1148
|
+
|
|
1149
|
+
`all_strings` is populated only by `_collect_sensitive_inventory`, which
|
|
1150
|
+
is a diagnostic and test helper — NOT part of the render path. It walks
|
|
1151
|
+
the whole dataclass/mapping/sequence graph generically, which is useful
|
|
1152
|
+
for asking "does this snapshot carry X anywhere", but `_verify_output`
|
|
1153
|
+
never reads it, so paying for that walk on every `render()` bought
|
|
1154
|
+
nothing.
|
|
1155
|
+
"""
|
|
1156
|
+
project_labels: frozenset[str] = frozenset()
|
|
1157
|
+
prepared_labels: frozenset[str] = frozenset()
|
|
1158
|
+
allowed_labels: frozenset[str] = frozenset()
|
|
1159
|
+
all_strings: frozenset[str] = frozenset()
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
EMPTY_INVENTORY = SensitiveInventory()
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _walk_strings(value: object, out: set[str], seen: set[int]) -> None:
|
|
1166
|
+
if isinstance(value, str):
|
|
1167
|
+
if value:
|
|
1168
|
+
out.add(value)
|
|
1169
|
+
return
|
|
1170
|
+
if value is None or isinstance(value, (bool, int, float, bytes, datetime)):
|
|
1171
|
+
return
|
|
1172
|
+
marker = id(value)
|
|
1173
|
+
if marker in seen:
|
|
1174
|
+
return
|
|
1175
|
+
seen.add(marker)
|
|
1176
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
1177
|
+
for f in dataclasses.fields(value):
|
|
1178
|
+
_walk_strings(getattr(value, f.name, None), out, seen)
|
|
1179
|
+
return
|
|
1180
|
+
if isinstance(value, Mapping):
|
|
1181
|
+
for key, item in value.items():
|
|
1182
|
+
_walk_strings(key, out, seen)
|
|
1183
|
+
_walk_strings(item, out, seen)
|
|
1184
|
+
return
|
|
1185
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
1186
|
+
for item in value:
|
|
1187
|
+
_walk_strings(item, out, seen)
|
|
1188
|
+
return
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _iter_chart_points(chart: "ChartSpec | None"):
|
|
1192
|
+
if chart is None:
|
|
1193
|
+
return
|
|
1194
|
+
yield from chart.points
|
|
1195
|
+
if isinstance(chart, LineChart) and chart.multi_series:
|
|
1196
|
+
for series in chart.multi_series.values():
|
|
1197
|
+
yield from series
|
|
1198
|
+
elif isinstance(chart, BarChart) and chart.stacks:
|
|
1199
|
+
for series in chart.stacks.values():
|
|
1200
|
+
yield from series
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _collect_sensitive_inventory(snap: ShareSnapshot) -> SensitiveInventory:
|
|
1204
|
+
"""Walk the whole snapshot graph and record every string it carries.
|
|
1205
|
+
|
|
1206
|
+
A DIAGNOSTIC and test helper, deliberately NOT on the render path. The
|
|
1207
|
+
generic walk answers "does this snapshot carry this token anywhere",
|
|
1208
|
+
which is what a test wants; `_verify_output` reads only the three
|
|
1209
|
+
project-provenance sets, so `render()` no longer pays for the walk.
|
|
1210
|
+
"""
|
|
1211
|
+
strings: set[str] = set()
|
|
1212
|
+
_walk_strings(snap, strings, set())
|
|
1213
|
+
return SensitiveInventory(
|
|
1214
|
+
all_strings=frozenset(strings),
|
|
1215
|
+
project_labels=frozenset(_project_display_labels(snap)),
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
# --- Preparation ---
|
|
1220
|
+
|
|
1221
|
+
def _resolved_project_labels(
|
|
1222
|
+
snap: ShareSnapshot, *, reveal_projects: bool,
|
|
1223
|
+
) -> dict[_ProjectAnonKey, str]:
|
|
1224
|
+
"""Map each project identity to the label the document should show.
|
|
1225
|
+
|
|
1226
|
+
Alias keys are collected from the FULL labels first — before any basename
|
|
1227
|
+
reduction — so two projects sharing a basename stay two identities.
|
|
1228
|
+
"""
|
|
1229
|
+
costs = _collect_project_identity_costs(snap)
|
|
1230
|
+
if not costs:
|
|
1231
|
+
return {}
|
|
1232
|
+
if not reveal_projects:
|
|
1233
|
+
return dict(_build_anon_mapping(costs))
|
|
1234
|
+
# Reveal: rank cost-descending (matching the alias ranking) so the
|
|
1235
|
+
# disambiguation order is stable, then reduce to displayed basenames.
|
|
1236
|
+
#
|
|
1237
|
+
# The basename comes from the DISPLAY LABEL, never from the key's second
|
|
1238
|
+
# element: for a legacy key those are the same string, but a qualified
|
|
1239
|
+
# key's second element is the opaque provider identity, and reducing that
|
|
1240
|
+
# would replace the user-facing label with an internal identifier.
|
|
1241
|
+
by_key = _project_label_by_key(snap)
|
|
1242
|
+
ordered = sorted(costs.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
1243
|
+
keys = [key for key, _cost in ordered]
|
|
1244
|
+
display = disambiguate_basenames([by_key.get(key, key[1]) for key in keys])
|
|
1245
|
+
return {key: display[idx] for idx, key in enumerate(keys)}
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _merged_project_costs(
|
|
1249
|
+
snaps: "Sequence[ShareSnapshot]",
|
|
1250
|
+
) -> dict[_ProjectAnonKey, float]:
|
|
1251
|
+
"""Accumulate project-identity costs across every section.
|
|
1252
|
+
|
|
1253
|
+
The `+=` matters: overwriting would let the last section's cost decide the
|
|
1254
|
+
global rank, so a project that appears twice would be ranked on half its
|
|
1255
|
+
spend.
|
|
1256
|
+
"""
|
|
1257
|
+
costs: dict[_ProjectAnonKey, float] = {}
|
|
1258
|
+
for snap in snaps:
|
|
1259
|
+
for key, cost in _collect_project_identity_costs(snap).items():
|
|
1260
|
+
costs[key] = costs.get(key, 0.0) + cost
|
|
1261
|
+
return costs
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _merged_project_mapping(
|
|
1265
|
+
snaps: "Sequence[ShareSnapshot]", *, reveal_projects: bool,
|
|
1266
|
+
) -> dict[_ProjectAnonKey, str]:
|
|
1267
|
+
"""One project display mapping shared by every section of a document.
|
|
1268
|
+
|
|
1269
|
+
Provider qualification is preserved deliberately: the same directory used
|
|
1270
|
+
under both Claude and Codex keeps two distinct aliases, which
|
|
1271
|
+
`test_equal_labels_with_distinct_qualified_identities_get_distinct_aliases`
|
|
1272
|
+
pins as a shipped invariant. Within one provider, one alias means one
|
|
1273
|
+
project.
|
|
1274
|
+
|
|
1275
|
+
The map is built BEFORE any basename reduction, so alias keys still derive
|
|
1276
|
+
from the full identities.
|
|
1277
|
+
"""
|
|
1278
|
+
costs = _merged_project_costs(snaps)
|
|
1279
|
+
if not costs:
|
|
1280
|
+
return {}
|
|
1281
|
+
if not reveal_projects:
|
|
1282
|
+
return dict(_build_anon_mapping(costs))
|
|
1283
|
+
by_key: dict[_ProjectAnonKey, str] = {}
|
|
1284
|
+
for snap in snaps:
|
|
1285
|
+
for key, label in _project_label_by_key(snap).items():
|
|
1286
|
+
by_key.setdefault(key, label)
|
|
1287
|
+
ordered = sorted(costs.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
1288
|
+
keys = [key for key, _cost in ordered]
|
|
1289
|
+
display = disambiguate_basenames([by_key.get(key, key[1]) for key in keys])
|
|
1290
|
+
return {key: display[idx] for idx, key in enumerate(keys)}
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _merged_anon_mapping(
|
|
1294
|
+
sections: "Sequence[ComposedSection]",
|
|
1295
|
+
) -> dict[_ProjectAnonKey, str]:
|
|
1296
|
+
"""The anonymize-mode alias namespace for a composed document."""
|
|
1297
|
+
return _merged_project_mapping(
|
|
1298
|
+
[sec.snap for sec in sections], reveal_projects=False)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
# --- The single enumeration of project display sites -----------------------
|
|
1302
|
+
#
|
|
1303
|
+
# `_scrub` leaked because it hand-enumerated three field sites, and F1, F2
|
|
1304
|
+
# and the `x_label` fall-through were three instances of that one shape.
|
|
1305
|
+
# Replacing it with a second hand-enumeration would only move the shape:
|
|
1306
|
+
# preparation would rewrite a set of fields, provenance collection would read
|
|
1307
|
+
# a DIFFERENT set, and the two would drift the first time someone added a
|
|
1308
|
+
# project display field to only one of them.
|
|
1309
|
+
#
|
|
1310
|
+
# So there is exactly ONE enumeration, `_map_project_display`, and every
|
|
1311
|
+
# consumer derives from it:
|
|
1312
|
+
#
|
|
1313
|
+
# `_apply_project_mapping` rewrites the sites (preparation)
|
|
1314
|
+
# `_project_display_labels` reads them (provenance, both halves)
|
|
1315
|
+
# `_project_label_by_key` reads the KEYED ones (reveal-mode basenames)
|
|
1316
|
+
#
|
|
1317
|
+
# Adding a project display field therefore means editing `_map_project_display`
|
|
1318
|
+
# and nothing else; a field reachable by preparation but invisible to
|
|
1319
|
+
# provenance can no longer be constructed.
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
@dataclass(frozen=True)
|
|
1323
|
+
class _ProjectDisplaySite:
|
|
1324
|
+
"""One typed project display value, as seen by the single enumeration.
|
|
1325
|
+
|
|
1326
|
+
`kind` distinguishes the KEYED sites — whose value is a project label and
|
|
1327
|
+
therefore mints a `_ProjectAnonKey` — from the DERIVED `chart_x_label`
|
|
1328
|
+
site, whose value is composed from another site's resolution and must
|
|
1329
|
+
never be used as a key.
|
|
1330
|
+
"""
|
|
1331
|
+
kind: str # "cell" | "column" | "chart_project_label"
|
|
1332
|
+
# | "chart_x_label"
|
|
1333
|
+
value: "str | None" # what is at the site right now
|
|
1334
|
+
identity: "str | None" = None # the project identity governing the site
|
|
1335
|
+
prefix: "str | None" = None # x_label_prefix (chart_x_label only)
|
|
1336
|
+
resolved: "str | None" = None # the label already resolved for this point
|
|
1337
|
+
|
|
1338
|
+
@property
|
|
1339
|
+
def keyed(self) -> bool:
|
|
1340
|
+
return self.kind != "chart_x_label"
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _map_project_display(
|
|
1344
|
+
snap: ShareSnapshot, visit: "Callable[[_ProjectDisplaySite], str | None]",
|
|
1345
|
+
) -> ShareSnapshot:
|
|
1346
|
+
"""Walk every typed project display site, replacing each with `visit`.
|
|
1347
|
+
|
|
1348
|
+
THE enumeration. A read-only consumer passes a `visit` that records the
|
|
1349
|
+
site and returns its value unchanged; preparation passes one that resolves
|
|
1350
|
+
from the alias/basename mapping.
|
|
1351
|
+
|
|
1352
|
+
The sites, in full:
|
|
1353
|
+
|
|
1354
|
+
* `ProjectCell.label` for every `ProjectCell` in every row.
|
|
1355
|
+
* `ColumnSpec.label` for every column with `kind == "project"`.
|
|
1356
|
+
* `ChartPoint.project_label` for the chart's points, plus
|
|
1357
|
+
`LineChart.multi_series` and `BarChart.stacks`.
|
|
1358
|
+
* `ChartPoint.x_label` where the axis is project-keyed — derived from
|
|
1359
|
+
the point's resolved `project_label` rather than resolved on its own.
|
|
1360
|
+
|
|
1361
|
+
A `"plain"` axis is preserved untouched.
|
|
1362
|
+
"""
|
|
961
1363
|
new_rows: list[Row] = []
|
|
962
1364
|
for row in snap.rows:
|
|
963
1365
|
new_cells: dict[str, Cell] = {}
|
|
964
1366
|
for key, cell in row.cells.items():
|
|
965
1367
|
if isinstance(cell, ProjectCell):
|
|
966
|
-
project_key = _project_anon_key(cell.label, cell.identity)
|
|
967
1368
|
new_cells[key] = ProjectCell(
|
|
968
|
-
|
|
1369
|
+
visit(_ProjectDisplaySite(
|
|
1370
|
+
kind="cell", value=cell.label, identity=cell.identity)),
|
|
969
1371
|
rank_cost=cell.rank_cost,
|
|
970
1372
|
identity=cell.identity,
|
|
971
1373
|
)
|
|
@@ -973,121 +1375,610 @@ def _apply_anon_mapping(
|
|
|
973
1375
|
new_cells[key] = cell
|
|
974
1376
|
new_rows.append(Row(cells=new_cells))
|
|
975
1377
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1378
|
+
def _rewrite_pt(p: ChartPoint) -> ChartPoint:
|
|
1379
|
+
new_label = (
|
|
1380
|
+
visit(_ProjectDisplaySite(
|
|
1381
|
+
kind="chart_project_label", value=p.project_label,
|
|
1382
|
+
identity=p.project_identity))
|
|
1383
|
+
if p.project_label else None
|
|
1384
|
+
)
|
|
1385
|
+
# Two independent triggers. `x_label_kind` is the authoritative one.
|
|
1386
|
+
# String equality is retained only because `_scrub` stays public and
|
|
1387
|
+
# a caller may hand it a hand-built point that predates the
|
|
1388
|
+
# discriminator; it is subsumed by the marker at every shipped
|
|
1389
|
+
# construction site, and it can never widen the leak surface — the
|
|
1390
|
+
# worst it can do is anonymize an axis that already displayed the
|
|
1391
|
+
# project name.
|
|
1392
|
+
is_project_axis = (
|
|
1393
|
+
p.x_label_kind == "project"
|
|
1394
|
+
or bool(p.project_label and p.x_label == p.project_label)
|
|
1395
|
+
)
|
|
1396
|
+
new_x = (
|
|
1397
|
+
visit(_ProjectDisplaySite(
|
|
1398
|
+
kind="chart_x_label", value=p.x_label,
|
|
1399
|
+
identity=p.project_identity, prefix=p.x_label_prefix,
|
|
1400
|
+
resolved=new_label))
|
|
1401
|
+
if is_project_axis else p.x_label
|
|
1402
|
+
)
|
|
1403
|
+
return ChartPoint(
|
|
1404
|
+
x_label=new_x,
|
|
1405
|
+
x_value=p.x_value,
|
|
1406
|
+
y_value=p.y_value,
|
|
1407
|
+
project_label=new_label,
|
|
1408
|
+
series_key=p.series_key,
|
|
1409
|
+
project_identity=p.project_identity,
|
|
1410
|
+
x_label_kind=p.x_label_kind,
|
|
1411
|
+
x_label_prefix=p.x_label_prefix,
|
|
1412
|
+
)
|
|
1006
1413
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
)
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
)
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1414
|
+
new_chart: ChartSpec | None = snap.chart
|
|
1415
|
+
if isinstance(snap.chart, LineChart):
|
|
1416
|
+
new_chart = LineChart(
|
|
1417
|
+
points=tuple(_rewrite_pt(p) for p in snap.chart.points),
|
|
1418
|
+
y_label=snap.chart.y_label,
|
|
1419
|
+
reference_lines=snap.chart.reference_lines,
|
|
1420
|
+
multi_series=(
|
|
1421
|
+
{k: tuple(_rewrite_pt(p) for p in v)
|
|
1422
|
+
for k, v in snap.chart.multi_series.items()}
|
|
1423
|
+
if snap.chart.multi_series else None
|
|
1424
|
+
),
|
|
1425
|
+
)
|
|
1426
|
+
elif isinstance(snap.chart, BarChart):
|
|
1427
|
+
new_chart = BarChart(
|
|
1428
|
+
points=tuple(_rewrite_pt(p) for p in snap.chart.points),
|
|
1429
|
+
y_label=snap.chart.y_label,
|
|
1430
|
+
stacks=(
|
|
1431
|
+
{k: tuple(_rewrite_pt(p) for p in v)
|
|
1432
|
+
for k, v in snap.chart.stacks.items()}
|
|
1433
|
+
if snap.chart.stacks else None
|
|
1434
|
+
),
|
|
1435
|
+
)
|
|
1436
|
+
elif isinstance(snap.chart, HorizontalBarChart):
|
|
1437
|
+
new_chart = HorizontalBarChart(
|
|
1438
|
+
points=tuple(_rewrite_pt(p) for p in snap.chart.points),
|
|
1439
|
+
x_label=snap.chart.x_label,
|
|
1440
|
+
cap=snap.chart.cap,
|
|
1441
|
+
)
|
|
1034
1442
|
|
|
1035
|
-
# Rewrite project-typed column headers (cross-tab Detail templates, issue
|
|
1036
|
-
# #33). Fail-closed: any column.label not in `mapping` maps to "(unknown)",
|
|
1037
|
-
# mirroring the ChartPoint arm above. Frozen-dataclass-compliant — we emit
|
|
1038
|
-
# a new tuple of new ColumnSpec instances, never mutate snap.columns.
|
|
1039
1443
|
new_columns: list[ColumnSpec] = []
|
|
1040
1444
|
for col in snap.columns:
|
|
1041
1445
|
if col.kind == "project":
|
|
1042
|
-
new_label = tagged_mapping.get(
|
|
1043
|
-
_project_anon_key(col.label, col.project_identity), "(unknown)",
|
|
1044
|
-
)
|
|
1045
1446
|
new_columns.append(ColumnSpec(
|
|
1046
|
-
key=col.key,
|
|
1447
|
+
key=col.key,
|
|
1448
|
+
label=visit(_ProjectDisplaySite(
|
|
1449
|
+
kind="column", value=col.label,
|
|
1450
|
+
identity=col.project_identity)),
|
|
1047
1451
|
align=col.align, emphasis=col.emphasis, kind=col.kind,
|
|
1048
1452
|
project_identity=col.project_identity,
|
|
1049
1453
|
))
|
|
1050
1454
|
else:
|
|
1051
1455
|
new_columns.append(col)
|
|
1052
1456
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
return ShareSnapshot(
|
|
1056
|
-
cmd=snap.cmd,
|
|
1057
|
-
title=snap.title,
|
|
1058
|
-
subtitle=snap.subtitle,
|
|
1059
|
-
period=snap.period,
|
|
1060
|
-
columns=tuple(new_columns),
|
|
1061
|
-
rows=tuple(new_rows),
|
|
1062
|
-
chart=new_chart,
|
|
1063
|
-
totals=snap.totals,
|
|
1064
|
-
notes=snap.notes,
|
|
1065
|
-
generated_at=snap.generated_at,
|
|
1066
|
-
version=snap.version,
|
|
1067
|
-
template_id=snap.template_id,
|
|
1068
|
-
source=snap.source,
|
|
1069
|
-
source_label=snap.source_label,
|
|
1070
|
-
availability=snap.availability,
|
|
1071
|
-
availability_reason=snap.availability_reason,
|
|
1457
|
+
return dataclasses.replace(
|
|
1458
|
+
snap, columns=tuple(new_columns), rows=tuple(new_rows), chart=new_chart,
|
|
1072
1459
|
)
|
|
1073
1460
|
|
|
1074
1461
|
|
|
1075
|
-
def
|
|
1076
|
-
"""
|
|
1462
|
+
def _project_display_labels(snap: ShareSnapshot) -> set[str]:
|
|
1463
|
+
"""The typed project display values present in one snapshot.
|
|
1077
1464
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
`_build_anon_mapping`. If no project labels are present in the snapshot,
|
|
1082
|
-
also returns the original instance.
|
|
1465
|
+
Derived from `_map_project_display`, so it can never fall behind what
|
|
1466
|
+
preparation rewrites. The returned snapshot is discarded — the visitor
|
|
1467
|
+
returns each value unchanged, so this is a read.
|
|
1083
1468
|
"""
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1469
|
+
labels: set[str] = set()
|
|
1470
|
+
|
|
1471
|
+
def _record(site: _ProjectDisplaySite) -> "str | None":
|
|
1472
|
+
if site.value:
|
|
1473
|
+
labels.add(site.value)
|
|
1474
|
+
return site.value
|
|
1475
|
+
|
|
1476
|
+
_map_project_display(snap, _record)
|
|
1477
|
+
return labels
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _project_label_by_key(snap: ShareSnapshot) -> dict[_ProjectAnonKey, str]:
|
|
1481
|
+
"""First-seen display label per project identity key.
|
|
1482
|
+
|
|
1483
|
+
Only the KEYED sites contribute: a project-keyed `x_label` is composed
|
|
1484
|
+
from another site's resolution, so keying on it would mint a bogus
|
|
1485
|
+
`("legacy", "1 · project-1")` entry.
|
|
1486
|
+
"""
|
|
1487
|
+
out: dict[_ProjectAnonKey, str] = {}
|
|
1488
|
+
|
|
1489
|
+
def _record(site: _ProjectDisplaySite) -> "str | None":
|
|
1490
|
+
if site.keyed and site.value:
|
|
1491
|
+
out.setdefault(_project_anon_key(site.value, site.identity),
|
|
1492
|
+
site.value)
|
|
1493
|
+
return site.value
|
|
1494
|
+
|
|
1495
|
+
_map_project_display(snap, _record)
|
|
1496
|
+
return out
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _apply_project_mapping(
|
|
1500
|
+
snap: ShareSnapshot, mapping: dict[_ProjectAnonKey, str],
|
|
1501
|
+
) -> ShareSnapshot:
|
|
1502
|
+
"""Rewrite every typed project display field from `mapping`.
|
|
1503
|
+
|
|
1504
|
+
Fail closed: a key absent from the mapping resolves to `(unknown)` rather
|
|
1505
|
+
than falling through to the original value. The `x_label` arm used to be
|
|
1506
|
+
the one exception — it fell OPEN on a mapping miss while its sibling arms
|
|
1507
|
+
fell closed — which is the asymmetry this replaces.
|
|
1508
|
+
|
|
1509
|
+
A project-keyed axis composes its `x_label` from the resolved label, with
|
|
1510
|
+
the `x_label_prefix` (the cost rank) ahead of it when present.
|
|
1511
|
+
"""
|
|
1512
|
+
def _resolve(site: _ProjectDisplaySite) -> str:
|
|
1513
|
+
if site.kind == "chart_x_label":
|
|
1514
|
+
base = site.resolved if site.resolved is not None else ANON_UNKNOWN
|
|
1515
|
+
return f"{site.prefix} · {base}" if site.prefix else base
|
|
1516
|
+
if not site.value:
|
|
1517
|
+
return ANON_UNKNOWN
|
|
1518
|
+
return mapping.get(_project_anon_key(site.value, site.identity),
|
|
1519
|
+
ANON_UNKNOWN)
|
|
1520
|
+
|
|
1521
|
+
return _map_project_display(snap, _resolve)
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _prepare(
|
|
1525
|
+
snap: ShareSnapshot, *, reveal_projects: bool,
|
|
1526
|
+
mapping: "dict[_ProjectAnonKey, str] | None" = None,
|
|
1527
|
+
) -> ShareSnapshot:
|
|
1528
|
+
"""Resolve every typed project display field and stamp provenance.
|
|
1529
|
+
|
|
1530
|
+
Always returns a NEW object and never marks its input, so the caller's
|
|
1531
|
+
snapshot stays renderable more than once. `mapping` lets `compose()`
|
|
1532
|
+
supply one merged alias namespace shared across all its sections; when
|
|
1533
|
+
omitted the mapping is derived from this snapshot alone.
|
|
1534
|
+
"""
|
|
1535
|
+
if _is_prepared(snap):
|
|
1536
|
+
raise SharePreparationError(
|
|
1537
|
+
"snapshot is already prepared; entry points must receive raw "
|
|
1538
|
+
"snapshots (a second pass renumbers aliases on the legacy path "
|
|
1539
|
+
"and merges distinct projects in compose)"
|
|
1540
|
+
)
|
|
1541
|
+
originals = _project_display_labels(snap)
|
|
1542
|
+
resolved = (
|
|
1543
|
+
mapping if mapping is not None
|
|
1544
|
+
else _resolved_project_labels(snap, reveal_projects=reveal_projects)
|
|
1545
|
+
)
|
|
1546
|
+
out = _apply_project_mapping(snap, resolved)
|
|
1547
|
+
allowed = set(resolved.values())
|
|
1548
|
+
allowed.add(ANON_UNKNOWN)
|
|
1549
|
+
# A project-keyed axis emits `f"{prefix} · {resolved}"`, so the composed
|
|
1550
|
+
# forms belong on the allowlist too. They are derived from the RAW
|
|
1551
|
+
# snapshot's prefixes crossed with the mapping's values, never read back
|
|
1552
|
+
# off the prepared output, so the check stays a real comparison against
|
|
1553
|
+
# provenance rather than a tautology.
|
|
1554
|
+
prefixes = {
|
|
1555
|
+
point.x_label_prefix
|
|
1556
|
+
for point in _iter_chart_points(snap.chart)
|
|
1557
|
+
if point.x_label_kind == "project" and point.x_label_prefix
|
|
1558
|
+
}
|
|
1559
|
+
allowed |= {
|
|
1560
|
+
f"{prefix} · {value}" for prefix in prefixes for value in set(allowed)
|
|
1561
|
+
}
|
|
1562
|
+
object.__setattr__(out, _PREPARED_ATTR, _PreparedProvenance(
|
|
1563
|
+
reveal_projects=reveal_projects,
|
|
1564
|
+
originals=frozenset(originals),
|
|
1565
|
+
allowed=frozenset(allowed),
|
|
1566
|
+
))
|
|
1567
|
+
return out
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _inventory_for(
|
|
1571
|
+
raw: ShareSnapshot, prepared: ShareSnapshot,
|
|
1572
|
+
) -> SensitiveInventory:
|
|
1573
|
+
"""Build the verification inventory from one raw/prepared snapshot pair."""
|
|
1574
|
+
return _merge_inventories([(raw, prepared)])
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def has_project_identities(snap: ShareSnapshot) -> bool:
|
|
1578
|
+
"""True when this snapshot carries a project identity the privacy toggle
|
|
1579
|
+
can act on (#503 S1 B1).
|
|
1580
|
+
|
|
1581
|
+
Public, because the dashboard's render handler surfaces it so the share
|
|
1582
|
+
modal's status line can tell the user what the export will actually
|
|
1583
|
+
contain. Some renders produce artifacts that are byte-identical in both
|
|
1584
|
+
privacy modes apart from the `anonymized:` frontmatter line. Telling that
|
|
1585
|
+
user "Export will show real project names" is a false statement there, and
|
|
1586
|
+
a warning learned to be false on Forecast is one a user may disregard on
|
|
1587
|
+
Projects.
|
|
1588
|
+
|
|
1589
|
+
WHICH renders those are is not a property of the code and is deliberately
|
|
1590
|
+
not enumerated anywhere. It is a property of the snapshot actually built:
|
|
1591
|
+
the same template carries project names over one store and none over
|
|
1592
|
+
another, and the split runs WITHIN a panel as well as between panels, so
|
|
1593
|
+
neither a panel list nor a template list can be right. Three independent
|
|
1594
|
+
counts taken during this session disagreed for exactly that reason — each
|
|
1595
|
+
was measured against a different dataset. Do not restore a number here.
|
|
1596
|
+
|
|
1597
|
+
Derived from `_project_display_labels`, hence from `_map_project_display`,
|
|
1598
|
+
the single enumeration of typed project display sites. A panel list would
|
|
1599
|
+
be a second source of truth and would be wrong at template granularity.
|
|
1600
|
+
|
|
1601
|
+
`(unknown)` does not count: it renders identically in both modes, so a
|
|
1602
|
+
snapshot carrying only it has nothing the toggle can change.
|
|
1603
|
+
"""
|
|
1604
|
+
return bool(_project_display_labels(snap) - {ANON_UNKNOWN})
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
def _merge_inventories(
|
|
1608
|
+
pairs: "Sequence[tuple[ShareSnapshot, ShareSnapshot]]",
|
|
1609
|
+
) -> SensitiveInventory:
|
|
1610
|
+
"""Fold the project provenance of every raw/prepared section pair.
|
|
1611
|
+
|
|
1612
|
+
Deliberately does NOT populate `all_strings`: `_verify_output` never
|
|
1613
|
+
reads it, so walking the whole snapshot graph here made every `render()`
|
|
1614
|
+
pay for a value nothing consumed. `_collect_sensitive_inventory` still
|
|
1615
|
+
offers that walk as a diagnostic.
|
|
1616
|
+
"""
|
|
1617
|
+
originals: set[str] = set()
|
|
1618
|
+
prepared_labels: set[str] = set()
|
|
1619
|
+
allowed: set[str] = set()
|
|
1620
|
+
for raw, prepared in pairs:
|
|
1621
|
+
originals |= _project_display_labels(raw)
|
|
1622
|
+
prepared_labels |= _project_display_labels(prepared)
|
|
1623
|
+
prov = _provenance_of(prepared)
|
|
1624
|
+
if prov is not None:
|
|
1625
|
+
allowed |= set(prov.allowed)
|
|
1626
|
+
return SensitiveInventory(
|
|
1627
|
+
project_labels=frozenset(originals),
|
|
1628
|
+
prepared_labels=frozenset(prepared_labels),
|
|
1629
|
+
allowed_labels=frozenset(allowed),
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
# --- Verification: the forbidden-class detector (#503 S1) ---
|
|
1634
|
+
#
|
|
1635
|
+
# Stage 4 of the contract. DETECTION ONLY — a finding raises
|
|
1636
|
+
# `SharePrivacyViolation` and the render fails. It never redacts and
|
|
1637
|
+
# continues, because a redact-and-continue gate hides the builder defect that
|
|
1638
|
+
# put the identifier in the document, and the operator's decision is that a
|
|
1639
|
+
# share artifact which cannot be produced safely is not produced.
|
|
1640
|
+
#
|
|
1641
|
+
# Verification has TWO DISJOINT HALVES, and the split is load-bearing.
|
|
1642
|
+
#
|
|
1643
|
+
# Half one — provenance-checked fields. Preparation knows which fields it
|
|
1644
|
+
# rewrote and what it was allowed to write there, so verification compares the
|
|
1645
|
+
# emitted values against that allowlist. It never searches the document for
|
|
1646
|
+
# original project labels.
|
|
1647
|
+
#
|
|
1648
|
+
# Half two — unambiguous classes, scanned document-wide. Only identifier
|
|
1649
|
+
# classes that cannot plausibly occur as legitimate artifact content.
|
|
1650
|
+
#
|
|
1651
|
+
# Original project labels are deliberately NOT in half two. A project
|
|
1652
|
+
# legitimately named `cctally` collides with the static branding string this
|
|
1653
|
+
# module emits, so a whole-document rejected-token set would fail a correctly
|
|
1654
|
+
# anonymized artifact; `daily` and `svg` collide with ordinary chrome and
|
|
1655
|
+
# markup the same way. Under the fail-the-render decision that is an outage,
|
|
1656
|
+
# not a nuisance, so half one covers the real risk without the collision.
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
class SharePrivacyViolation(Exception):
|
|
1660
|
+
"""A forbidden identifier class was found in a rendered share artifact.
|
|
1661
|
+
|
|
1662
|
+
`classes` names the finding classes and NOTHING else — the message carries
|
|
1663
|
+
the matched value, this attribute deliberately does not. The two audiences
|
|
1664
|
+
differ: a user staring at a several-hundred-row artifact cannot act on
|
|
1665
|
+
"canonical UUID" alone, so the message names the value; a log line has no
|
|
1666
|
+
such need, and a dashboard log is a plausible thing to paste into a bug
|
|
1667
|
+
report (#503 S1 R10).
|
|
1668
|
+
|
|
1669
|
+
The default is the fail-closed sentinel rather than an empty tuple: a
|
|
1670
|
+
caller that redacts by reading `classes` treats a falsy value as "nothing
|
|
1671
|
+
to redact by" and falls back to the full `repr`, so a raise site that
|
|
1672
|
+
forgot the keyword would put the matched value into the log — the exact
|
|
1673
|
+
outcome this attribute exists to prevent, and silently. With the sentinel
|
|
1674
|
+
the redaction is uninformative instead of unsafe. The structural tripwire
|
|
1675
|
+
`test_every_privacy_raise_site_names_its_classes` keeps raise sites from
|
|
1676
|
+
relying on it.
|
|
1677
|
+
"""
|
|
1678
|
+
|
|
1679
|
+
UNCLASSIFIED = "unclassified privacy violation"
|
|
1680
|
+
|
|
1681
|
+
def __init__(
|
|
1682
|
+
self, message: str, *, classes: "Sequence[str]" = (UNCLASSIFIED,),
|
|
1683
|
+
) -> None:
|
|
1684
|
+
super().__init__(message)
|
|
1685
|
+
self.classes: tuple[str, ...] = tuple(classes) or (self.UNCLASSIFIED,)
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
# Canonical UUID with exact 8-4-4-4-12 hex grouping. Claude session ids are
|
|
1689
|
+
# canonical UUIDs, which is what F1 disclosed through the sessions charts.
|
|
1690
|
+
_UUID_RE = re.compile(
|
|
1691
|
+
r"(?<![0-9A-Fa-f-])"
|
|
1692
|
+
r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"
|
|
1693
|
+
r"(?![0-9A-Fa-f-])"
|
|
1694
|
+
)
|
|
1695
|
+
|
|
1696
|
+
# A URI with a scheme and an authority. Stripped BEFORE the absolute-path scan
|
|
1697
|
+
# so its path component cannot be read as a filesystem path — the shipped
|
|
1698
|
+
# branded goldens carry `https://github.com/omrikais/cctally` and
|
|
1699
|
+
# `http://www.w3.org/2000/svg`, and a naive path predicate matches both.
|
|
1700
|
+
_URI_RE = re.compile(r"""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>)\]]*""")
|
|
1701
|
+
|
|
1702
|
+
# An absolute POSIX path of at least two segments. The lookbehind is the
|
|
1703
|
+
# exclusion rule: a `/` preceded by a word character, `:`, `/`, `<` or an
|
|
1704
|
+
# attribute quote is a URI or markup component, not a path start. That covers
|
|
1705
|
+
# the bare-host form `github.com/omrikais/cctally` (preceded by `m`), the
|
|
1706
|
+
# scheme-relative form (preceded by `/`), and closing / self-closing tags
|
|
1707
|
+
# (preceded by `<` or forming a single segment).
|
|
1708
|
+
_ABS_PATH_RE = re.compile(
|
|
1709
|
+
r"""(?<![A-Za-z0-9._\-:/<"'])(?:/[A-Za-z0-9._~+\-]+){2,}"""
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
# `~/`, `~user/`, `$HOME/`, `${HOME}/` home expansions. The trailing separator
|
|
1713
|
+
# is required so the `~` prefix `blocks` uses for a heuristically-anchored row
|
|
1714
|
+
# is not a finding.
|
|
1715
|
+
_HOME_EXPANSION_RE = re.compile(
|
|
1716
|
+
r"""(?<![A-Za-z0-9._\-])(?:~[A-Za-z0-9._\-]*|\$\{?HOME\}?)/[A-Za-z0-9._~+\-]"""
|
|
1717
|
+
)
|
|
1718
|
+
|
|
1719
|
+
_EMAIL_RE = re.compile(
|
|
1720
|
+
r"(?<![A-Za-z0-9._%+\-])[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"
|
|
1721
|
+
r"(?![A-Za-z0-9.\-])"
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
# `source_root_key` / `codex_file_key` are 32-character lowercase hex. The
|
|
1725
|
+
# lookarounds keep this from matching inside a 64-character sha256 digest.
|
|
1726
|
+
_SOURCE_ROOT_KEY_RE = re.compile(r"(?<![0-9A-Fa-f])[0-9a-f]{32}(?![0-9A-Fa-f])")
|
|
1727
|
+
|
|
1728
|
+
# The canonical logical-limit JSON object `_lib_jsonl._codex_logical_limit_key`
|
|
1729
|
+
# emits. Matched on the co-occurrence of its identity members rather than on
|
|
1730
|
+
# the whole literal, so a member added later still trips it.
|
|
1731
|
+
_LOGICAL_LIMIT_MEMBERS = ('"observedSlot"', '"windowMinutes"', '"sourceRootKey"')
|
|
1732
|
+
|
|
1733
|
+
_V1_IDENTITY_RE = re.compile(r"(?<![A-Za-z0-9._\-])v1\.[A-Za-z0-9_\-]{16,}")
|
|
1734
|
+
_V1_IDENTITY_MEMBERS = frozenset(
|
|
1735
|
+
{"nativeKey", "resourceKind", "source", "version"})
|
|
1736
|
+
|
|
1737
|
+
# High-precision credential shapes. These mirror
|
|
1738
|
+
# `_lib_conversation_anon.SECRET_PATTERNS`, deliberately COPIED rather than
|
|
1739
|
+
# imported: that module's guarantee explicitly excludes emails, session ids and
|
|
1740
|
+
# unknown identities, so reusing it here would import a weaker contract than
|
|
1741
|
+
# this gate promises. They are used as DETECTION, never as redaction.
|
|
1742
|
+
#
|
|
1743
|
+
# LEFT BOUNDARY, and why it is mandatory HERE specifically. The source
|
|
1744
|
+
# patterns carry no left anchor, so `sk-[A-Za-z0-9_\-]{20,}` matches inside
|
|
1745
|
+
# any word ending in `sk` — `flask-restful-api-server-example`,
|
|
1746
|
+
# `risk-analysis-toolkit-2026`, `desk-booking-service-frontend` — and
|
|
1747
|
+
# `sk-ant-[…]` matches inside `flask-ant-design-theme-kit`. In the source
|
|
1748
|
+
# module an unanchored match only OVER-REDACTS. Here detection FAILS the
|
|
1749
|
+
# render, so the same regex is a shipping outage: the user cannot rename
|
|
1750
|
+
# their repository in order to share a report. `_CRED_LEFT` requires the
|
|
1751
|
+
# match to start at a non-word position, which is where a real credential
|
|
1752
|
+
# always starts (after whitespace, a quote, `=`, `:` or the string start).
|
|
1753
|
+
#
|
|
1754
|
+
# WHAT THE ANCHOR GIVES UP. `_CRED_LEFT` excludes `-` and `_` from the
|
|
1755
|
+
# preceding position as well as alphanumerics, so a credential glued to a
|
|
1756
|
+
# word character on its left — `-sk-ant-api03-…` in a flag-like string, or
|
|
1757
|
+
# `KEY_sk-ant-…` — is no longer detected. That narrowing is deliberate and is
|
|
1758
|
+
# the price of the anchor: every realistic embedding of a real credential in a
|
|
1759
|
+
# rendered artifact (after a space, a quote, `=`, `:`, `/`, or at the start of
|
|
1760
|
+
# the string) still fires, while the ordinary-repository-name false positives
|
|
1761
|
+
# above, which under the fail-the-render decision leave the user no recourse,
|
|
1762
|
+
# do not.
|
|
1763
|
+
_CRED_LEFT = r"(?<![A-Za-z0-9_\-])"
|
|
1764
|
+
_CREDENTIAL_RES = (
|
|
1765
|
+
("authorization-header", re.compile(r"\bAuthorization:[ \t]*\S", re.I)),
|
|
1766
|
+
("bearer-token", re.compile(r"\bBearer[ \t]+[A-Za-z0-9._~+/=-]{16,}", re.I)),
|
|
1767
|
+
("anthropic-key", re.compile(_CRED_LEFT + r"sk-ant-[A-Za-z0-9_\-]{8,}")),
|
|
1768
|
+
("generic-sk-key", re.compile(_CRED_LEFT + r"sk-[A-Za-z0-9_\-]{20,}")),
|
|
1769
|
+
("github-token", re.compile(
|
|
1770
|
+
_CRED_LEFT + r"(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{16,}")),
|
|
1771
|
+
("aws-access-key", re.compile(_CRED_LEFT + r"AKIA[0-9A-Z]{16}")),
|
|
1772
|
+
("slack-token", re.compile(_CRED_LEFT + r"xox[baprs]-[A-Za-z0-9\-]{10,}")),
|
|
1773
|
+
("secret-assignment", re.compile(
|
|
1774
|
+
r"\b(?:api[_-]?key|secret|passwd|password)\b[ \t]*[=:][ \t]*"
|
|
1775
|
+
r"""(?:"[^"\r\n]{6,}"|'[^'\r\n]{6,}'|[^\s"']{6,})""", re.I)),
|
|
1776
|
+
)
|
|
1777
|
+
|
|
1778
|
+
_NUMERIC_ENTITY_RE = re.compile(r"&#(x[0-9A-Fa-f]+|[0-9]+);")
|
|
1779
|
+
_MD_BACKSLASH_RE = re.compile(r"\\([\\|*_`\[\]])")
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
def _decode_entities(text: str) -> str:
|
|
1783
|
+
"""Undo the XML/HTML escaping the renderers apply."""
|
|
1784
|
+
def _numeric(m: "re.Match[str]") -> str:
|
|
1785
|
+
token = m.group(1)
|
|
1786
|
+
try:
|
|
1787
|
+
code = int(token[1:], 16) if token[0] in "xX" else int(token)
|
|
1788
|
+
except ValueError:
|
|
1789
|
+
return m.group(0)
|
|
1790
|
+
return chr(code) if 0 < code < 0x110000 else m.group(0)
|
|
1791
|
+
|
|
1792
|
+
out = _NUMERIC_ENTITY_RE.sub(_numeric, text)
|
|
1793
|
+
for entity, char in ((""", '"'), ("'", "'"), ("<", "<"),
|
|
1794
|
+
(">", ">"), ("&", "&")):
|
|
1795
|
+
out = out.replace(entity, char)
|
|
1796
|
+
return out
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def _scan_variants(text: str) -> tuple[str, ...]:
|
|
1800
|
+
"""The raw document plus its decoded forms.
|
|
1801
|
+
|
|
1802
|
+
Entity encoding and markdown backslash escaping both split a token across
|
|
1803
|
+
characters the scanners would otherwise not join up, so scanning the raw
|
|
1804
|
+
bytes alone would let an encoded identifier through.
|
|
1805
|
+
"""
|
|
1806
|
+
decoded = _decode_entities(text)
|
|
1807
|
+
unescaped = _MD_BACKSLASH_RE.sub(r"\1", decoded)
|
|
1808
|
+
variants = [text]
|
|
1809
|
+
for candidate in (decoded, unescaped):
|
|
1810
|
+
if candidate not in variants:
|
|
1811
|
+
variants.append(candidate)
|
|
1812
|
+
return tuple(variants)
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def _looks_like_identity_key(token: str) -> bool:
|
|
1816
|
+
"""True when a `v1.` token base64url-decodes to a canonical IdentityV1."""
|
|
1817
|
+
body = token[3:]
|
|
1818
|
+
padded = body + "=" * (-len(body) % 4)
|
|
1819
|
+
try:
|
|
1820
|
+
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
1821
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
1822
|
+
except Exception:
|
|
1823
|
+
return False
|
|
1824
|
+
return isinstance(payload, dict) and _V1_IDENTITY_MEMBERS <= set(payload)
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
# How much of an offending value the refusal message quotes.
|
|
1828
|
+
#
|
|
1829
|
+
# The message reaches a terminal (`cctally: refused to write a share artifact
|
|
1830
|
+
# — …`), so an unbounded value would wrap the refusal off screen. Long enough
|
|
1831
|
+
# that a user recognizes which of their directories or identifiers tripped the
|
|
1832
|
+
# gate, which is the whole reason the value is named at all.
|
|
1833
|
+
_FINDING_SAMPLE_MAX = 48
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
def _finding_sample(value: str) -> str:
|
|
1837
|
+
"""A single-line, length-bounded form of a matched value."""
|
|
1838
|
+
collapsed = " ".join(value.split())
|
|
1839
|
+
if len(collapsed) <= _FINDING_SAMPLE_MAX:
|
|
1840
|
+
return collapsed
|
|
1841
|
+
return collapsed[:_FINDING_SAMPLE_MAX] + "…"
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
# The run of characters a path-shaped token may continue with. Used ONLY to
|
|
1845
|
+
# widen a match for display: `_HOME_EXPANSION_RE` requires a single character
|
|
1846
|
+
# after the separator, which is the right anchor for detection but reports
|
|
1847
|
+
# `~/w` as the offending value where the user needs to see `~/work/app`.
|
|
1848
|
+
# Widening here rather than in the pattern keeps detection semantics fixed.
|
|
1849
|
+
_PATHY_TAIL_RE = re.compile(r"[A-Za-z0-9._~+\-/]*")
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def _matched_with_pathy_tail(text: str, match: "re.Match[str]") -> str:
|
|
1853
|
+
tail = _PATHY_TAIL_RE.match(text, match.end())
|
|
1854
|
+
return text[match.start():tail.end()]
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
def _scan_forbidden_classes(text: str) -> "list[tuple[str, str]]":
|
|
1858
|
+
"""Return `(class label, matched value)` per unambiguous class in `text`.
|
|
1859
|
+
|
|
1860
|
+
The matched value is carried out of the scan, not just the class name.
|
|
1861
|
+
Naming only the class leaves the user nothing to act on: the accepted
|
|
1862
|
+
limitation in `docs/share-gotchas.md` is that a project whose basename is
|
|
1863
|
+
itself a canonical UUID or a 32-character hex token cannot be rendered in
|
|
1864
|
+
reveal mode, and "canonical UUID" alone does not tell that user which of
|
|
1865
|
+
their directories to rename.
|
|
1866
|
+
"""
|
|
1867
|
+
findings: list[tuple[str, str]] = []
|
|
1868
|
+
match = _UUID_RE.search(text)
|
|
1869
|
+
if match:
|
|
1870
|
+
findings.append(("canonical UUID", match.group(0)))
|
|
1871
|
+
for token in _V1_IDENTITY_RE.findall(text):
|
|
1872
|
+
if _looks_like_identity_key(token):
|
|
1873
|
+
findings.append(("v1. identity key", token))
|
|
1874
|
+
break
|
|
1875
|
+
without_uris = _URI_RE.sub(" ", text)
|
|
1876
|
+
match = _ABS_PATH_RE.search(without_uris)
|
|
1877
|
+
if match:
|
|
1878
|
+
findings.append(("absolute path", match.group(0)))
|
|
1879
|
+
match = _HOME_EXPANSION_RE.search(text)
|
|
1880
|
+
if match:
|
|
1881
|
+
findings.append(
|
|
1882
|
+
("home-directory expansion", _matched_with_pathy_tail(text, match)))
|
|
1883
|
+
match = _EMAIL_RE.search(text)
|
|
1884
|
+
if match:
|
|
1885
|
+
findings.append(("email address", match.group(0)))
|
|
1886
|
+
match = _SOURCE_ROOT_KEY_RE.search(text)
|
|
1887
|
+
if match:
|
|
1888
|
+
findings.append(("source-root key", match.group(0)))
|
|
1889
|
+
if all(member in text for member in _LOGICAL_LIMIT_MEMBERS):
|
|
1890
|
+
# No single regex match to quote; the members are what identify it.
|
|
1891
|
+
first = min(_LOGICAL_LIMIT_MEMBERS, key=text.index)
|
|
1892
|
+
findings.append(("logical-limit identity", first))
|
|
1893
|
+
for name, pattern in _CREDENTIAL_RES:
|
|
1894
|
+
match = pattern.search(text)
|
|
1895
|
+
if match:
|
|
1896
|
+
findings.append((f"credential ({name})", match.group(0)))
|
|
1897
|
+
return findings
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _describe_findings(findings: "Sequence[tuple[str, str]]") -> str:
|
|
1901
|
+
"""Render `label (value)` per class, deduplicated, in a stable order."""
|
|
1902
|
+
seen: dict[str, str] = {}
|
|
1903
|
+
for label, value in findings:
|
|
1904
|
+
seen.setdefault(label, value)
|
|
1905
|
+
parts = [
|
|
1906
|
+
f"{label} ({_finding_sample(seen[label])})" for label in sorted(seen)
|
|
1907
|
+
]
|
|
1908
|
+
if len(parts) > 5:
|
|
1909
|
+
return ", ".join(parts[:5]) + f", and {len(parts) - 5} more"
|
|
1910
|
+
return ", ".join(parts)
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _verify_output(
|
|
1914
|
+
text: str, *, inventory: SensitiveInventory,
|
|
1915
|
+
) -> None:
|
|
1916
|
+
"""Raise `SharePrivacyViolation` when the rendered document is unsafe.
|
|
1917
|
+
|
|
1918
|
+
Detection only. Callers must let the exception propagate: the dashboard
|
|
1919
|
+
handler's exception converter turns it into the generic 500 envelope and
|
|
1920
|
+
the CLI converts it to a stderr refusal and exit 3.
|
|
1921
|
+
|
|
1922
|
+
Takes NO privacy mode. Both halves are mode-independent — half one
|
|
1923
|
+
compares against the allowlist preparation itself built under whichever
|
|
1924
|
+
mode was asked for, and half two's classes are forbidden in reveal mode
|
|
1925
|
+
too, because reveal discloses a project's basename and never its path.
|
|
1926
|
+
The parameter existed and was read by nothing.
|
|
1927
|
+
"""
|
|
1928
|
+
# Half one — provenance. Every project display value the prepared snapshot
|
|
1929
|
+
# carries must be one preparation was allowed to write. This catches a
|
|
1930
|
+
# preparation miss without a text search, so a project whose name happens
|
|
1931
|
+
# to be a common word is checked correctly.
|
|
1932
|
+
#
|
|
1933
|
+
# A CONSTRUCTION INVARIANT, not a runtime check that can fire in the real
|
|
1934
|
+
# pipeline: `_apply_project_mapping` writes every site from
|
|
1935
|
+
# `mapping.get(key, ANON_UNKNOWN)` and composes the axis label from the
|
|
1936
|
+
# same values, and `_prepare` builds `allowed` from that mapping plus the
|
|
1937
|
+
# raw prefixes, so the difference is empty by construction for every
|
|
1938
|
+
# in-tree input. It fires for a snapshot prepared outside `_prepare`, and
|
|
1939
|
+
# it is deliberately NOT strengthened into a document-wide search for
|
|
1940
|
+
# original project labels: a project named `cctally` collides with this
|
|
1941
|
+
# module's own branding string, and under the fail-the-render decision
|
|
1942
|
+
# that collision is an outage.
|
|
1943
|
+
if inventory.allowed_labels or inventory.prepared_labels:
|
|
1944
|
+
escaped = inventory.prepared_labels - inventory.allowed_labels
|
|
1945
|
+
if escaped:
|
|
1946
|
+
raise SharePrivacyViolation(
|
|
1947
|
+
"project display fields escaped preparation: "
|
|
1948
|
+
+ ", ".join(sorted(escaped)[:5]),
|
|
1949
|
+
# The escaped values ARE project labels, so they stay out of
|
|
1950
|
+
# `classes` for the same reason a matched value does.
|
|
1951
|
+
classes=("project display fields escaped preparation",),
|
|
1952
|
+
)
|
|
1953
|
+
|
|
1954
|
+
# Half two — unambiguous classes, document-wide, in both privacy modes.
|
|
1955
|
+
for variant in _scan_variants(text):
|
|
1956
|
+
findings = _scan_forbidden_classes(variant)
|
|
1957
|
+
if findings:
|
|
1958
|
+
raise SharePrivacyViolation(
|
|
1959
|
+
"share artifact would disclose: " + _describe_findings(findings),
|
|
1960
|
+
classes=sorted({label for label, _value in findings}),
|
|
1961
|
+
)
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def _encode_probe_identity_key() -> str:
|
|
1965
|
+
"""A syntactically valid IdentityV1 key, for the detector's own tests.
|
|
1966
|
+
|
|
1967
|
+
Kept next to the decoder so the probe cannot drift away from the shape the
|
|
1968
|
+
detector recognizes.
|
|
1969
|
+
"""
|
|
1970
|
+
payload = {
|
|
1971
|
+
"nativeKey": "probe",
|
|
1972
|
+
"parentKey": None,
|
|
1973
|
+
"resourceKind": "conversation",
|
|
1974
|
+
"source": "codex",
|
|
1975
|
+
"sourceRootKey": None,
|
|
1976
|
+
"version": 1,
|
|
1977
|
+
}
|
|
1978
|
+
canonical = json.dumps(
|
|
1979
|
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
1980
|
+
).encode("utf-8")
|
|
1981
|
+
return "v1." + base64.urlsafe_b64encode(canonical).decode("ascii").rstrip("=")
|
|
1091
1982
|
|
|
1092
1983
|
|
|
1093
1984
|
# --- Format renderers ---
|
|
@@ -1746,16 +2637,25 @@ def _build_md_frontmatter(snap: ShareSnapshot) -> str:
|
|
|
1746
2637
|
`template_id` is present for dashboard share-v2 snapshots and omitted
|
|
1747
2638
|
for legacy CLI snapshots that have no template recipe.
|
|
1748
2639
|
|
|
1749
|
-
`anonymized`
|
|
1750
|
-
|
|
1751
|
-
|
|
2640
|
+
`anonymized` reports the MODE the document was rendered in, read off the
|
|
2641
|
+
provenance marker preparation stamped: `not reveal_projects`. It used to
|
|
2642
|
+
be INFERRED by regex-matching `project-\\d+` over the labels, which was
|
|
2643
|
+
wrong three ways — it never inspected the chart, so `sessions-visual`
|
|
2644
|
+
stamped `false` onto a demonstrably scrubbed snapshot; a real project
|
|
2645
|
+
named `project-1` reported as anonymized; and a silently failed scrub
|
|
2646
|
+
producing conforming labels was indistinguishable from a successful one.
|
|
2647
|
+
|
|
2648
|
+
An UNPREPARED snapshot never went through the privacy contract, so the
|
|
2649
|
+
document cannot claim anonymization and reports `false`. That reaches
|
|
2650
|
+
only the `_render_md` back-compat shim; `render()` always prepares.
|
|
1752
2651
|
"""
|
|
1753
2652
|
period = snap.period
|
|
1754
2653
|
period_iso = (
|
|
1755
2654
|
f"{_format_generated_at_iso(period.start)}.."
|
|
1756
2655
|
f"{_format_generated_at_iso(period.end)}"
|
|
1757
2656
|
)
|
|
1758
|
-
|
|
2657
|
+
prov = _provenance_of(snap)
|
|
2658
|
+
anonymized = "true" if (prov is not None and not prov.reveal_projects) else "false"
|
|
1759
2659
|
lines = [
|
|
1760
2660
|
"---",
|
|
1761
2661
|
f"title: {_yaml_scalar(snap.title)}",
|
|
@@ -1790,41 +2690,6 @@ def _yaml_scalar(s: str) -> str:
|
|
|
1790
2690
|
return s
|
|
1791
2691
|
|
|
1792
2692
|
|
|
1793
|
-
def _snapshot_is_anonymized(snap: ShareSnapshot) -> bool:
|
|
1794
|
-
"""Return True if every project label (cell or column) is anon or sentinel.
|
|
1795
|
-
|
|
1796
|
-
`_scrub` rewrites labels to `project-<N>` (1-indexed, cost-descending).
|
|
1797
|
-
A snapshot with no `ProjectCell` rows AND no `kind='project'` columns
|
|
1798
|
-
returns False (nothing was anonymized because there was nothing to
|
|
1799
|
-
anonymize). `(unknown)` is the project-share sentinel for missing
|
|
1800
|
-
project_path (see `cmd_project`'s `_proj_label_for`) — it is never a
|
|
1801
|
-
revealed real name, so it is counted as also-anonymized. Mixed snapshots
|
|
1802
|
-
(some scrubbed, some revealed) are reported False to keep the
|
|
1803
|
-
frontmatter semantic ("are projects revealed in this MD?").
|
|
1804
|
-
|
|
1805
|
-
Cross-tab Detail templates (issue #33) carry project labels in
|
|
1806
|
-
`kind='project'` columns rather than `ProjectCell` rows; we walk both
|
|
1807
|
-
surfaces so MD frontmatter `anonymized:` stays correct for those panels.
|
|
1808
|
-
"""
|
|
1809
|
-
cells = [
|
|
1810
|
-
cell
|
|
1811
|
-
for row in snap.rows
|
|
1812
|
-
for cell in row.cells.values()
|
|
1813
|
-
if isinstance(cell, ProjectCell)
|
|
1814
|
-
]
|
|
1815
|
-
project_cols = [col for col in snap.columns if col.kind == "project"]
|
|
1816
|
-
if not cells and not project_cols:
|
|
1817
|
-
return False
|
|
1818
|
-
|
|
1819
|
-
def _is_anon(label: str) -> bool:
|
|
1820
|
-
return bool(re.fullmatch(r"project-\d+", label)) or label == "(unknown)"
|
|
1821
|
-
|
|
1822
|
-
return (
|
|
1823
|
-
all(_is_anon(c.label) for c in cells)
|
|
1824
|
-
and all(_is_anon(col.label) for col in project_cols)
|
|
1825
|
-
)
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
2693
|
# --- Fragment + wrap ---
|
|
1829
2694
|
|
|
1830
2695
|
def _render_fragment(snap: ShareSnapshot, *, format: str,
|
|
@@ -1906,21 +2771,41 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
|
|
|
1906
2771
|
wraps them all in composite chrome (one title, one footer, one outer
|
|
1907
2772
|
wrapper) per format-specific stitching rules in spec §4.3.
|
|
1908
2773
|
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
2774
|
+
The second complete-document boundary that owns the privacy contract
|
|
2775
|
+
(#503 S1). `sections` must carry RAW snapshots: `compose()` prepares them
|
|
2776
|
+
itself under `opts.reveal_projects`, stitches, and then verifies the whole
|
|
2777
|
+
composed document. Callers must not pre-scrub — a pre-scrubbed section
|
|
2778
|
+
reaching a second aliasing pass merges two distinct projects that each
|
|
2779
|
+
mapped locally to `project-1` into one alias.
|
|
1913
2780
|
"""
|
|
1914
2781
|
if not sections:
|
|
1915
2782
|
raise ValueError("compose requires at least one section")
|
|
1916
2783
|
fmt = opts.format
|
|
2784
|
+
# ONE alias namespace for the whole document (#503 S1 F4). The merged
|
|
2785
|
+
# mapping is built here in the kernel rather than in the handler, because
|
|
2786
|
+
# a handler-only fix would miss the CLI `source=all` path.
|
|
2787
|
+
merged = _merged_project_mapping(
|
|
2788
|
+
[sec.snap for sec in sections], reveal_projects=opts.reveal_projects)
|
|
2789
|
+
prepared = tuple(
|
|
2790
|
+
ComposedSection(
|
|
2791
|
+
snap=_prepare(sec.snap, reveal_projects=opts.reveal_projects,
|
|
2792
|
+
mapping=merged),
|
|
2793
|
+
drift_detected=sec.drift_detected,
|
|
2794
|
+
)
|
|
2795
|
+
for sec in sections
|
|
2796
|
+
)
|
|
1917
2797
|
if fmt == "html":
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2798
|
+
body = _stitch_html(prepared, opts=opts)
|
|
2799
|
+
elif fmt == "md":
|
|
2800
|
+
body = _stitch_md(prepared, opts=opts)
|
|
2801
|
+
elif fmt == "svg":
|
|
2802
|
+
body = _stitch_svg(prepared, opts=opts)
|
|
2803
|
+
else:
|
|
2804
|
+
raise ValueError(f"unknown format: {fmt!r}")
|
|
2805
|
+
_verify_output(body, inventory=_merge_inventories([
|
|
2806
|
+
(raw.snap, out.snap) for raw, out in zip(sections, prepared)
|
|
2807
|
+
]))
|
|
2808
|
+
return body
|
|
1924
2809
|
|
|
1925
2810
|
|
|
1926
2811
|
def _stitch_html(sections: tuple[ComposedSection, ...], *,
|
|
@@ -1975,11 +2860,11 @@ def _stitch_md(sections: tuple[ComposedSection, ...], *,
|
|
|
1975
2860
|
# union collapses).
|
|
1976
2861
|
earliest = min(sec.snap.period.start for sec in sections)
|
|
1977
2862
|
latest = max(sec.snap.period.end for sec in sections)
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2863
|
+
# #503 S1: the composite frontmatter reports the composite MODE.
|
|
2864
|
+
# `compose()` re-renders every section with `opts.reveal_projects`
|
|
2865
|
+
# and discards each section's add-time value, so the composite flag
|
|
2866
|
+
# is the whole truth about what the document contains.
|
|
2867
|
+
anon_field = "false" if opts.reveal_projects else "true"
|
|
1983
2868
|
parts.append(
|
|
1984
2869
|
"---\n"
|
|
1985
2870
|
f"title: {_yaml_scalar(opts.title)}\n"
|
|
@@ -2043,16 +2928,39 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
|
|
|
2043
2928
|
|
|
2044
2929
|
# --- Public dispatch ---
|
|
2045
2930
|
|
|
2046
|
-
def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool
|
|
2931
|
+
def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool,
|
|
2932
|
+
reveal_projects: bool) -> str:
|
|
2047
2933
|
"""Render a snapshot to the requested format.
|
|
2048
2934
|
|
|
2049
2935
|
Pure function: no I/O, no DB, no filesystem, no locks. Caller is
|
|
2050
2936
|
responsible for emitting the result (stdout/file/clipboard/open).
|
|
2051
2937
|
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2938
|
+
One of the two complete-document boundaries that own the privacy contract
|
|
2939
|
+
(#503 S1). It runs inventory -> prepare -> render -> verify. The gate goes
|
|
2940
|
+
here and in `compose()`, not in `_render_fragment` and not in
|
|
2941
|
+
`_wrap_document`, because composition bypasses the latter and a fragment
|
|
2942
|
+
is not a complete document.
|
|
2943
|
+
|
|
2944
|
+
`reveal_projects` is a REQUIRED keyword with no default. Three shipped
|
|
2945
|
+
sites had defaulted it open, and fixing three defaults leaves nothing
|
|
2946
|
+
stopping a fourth; a wrong default cannot exist where there is no
|
|
2947
|
+
default, so a caller that omits it raises `TypeError` at its own call
|
|
2948
|
+
site rather than silently revealing.
|
|
2949
|
+
|
|
2950
|
+
`snap` must be RAW. Passing an already-scrubbed or already-prepared
|
|
2951
|
+
snapshot renumbers aliases on the legacy path, so preparation refuses it.
|
|
2055
2952
|
"""
|
|
2953
|
+
inventory_source = snap
|
|
2954
|
+
prepared = _prepare(snap, reveal_projects=reveal_projects)
|
|
2955
|
+
out = _render_prepared(prepared, format=format, theme=theme,
|
|
2956
|
+
branding=branding)
|
|
2957
|
+
_verify_output(out, inventory=_inventory_for(inventory_source, prepared))
|
|
2958
|
+
return out
|
|
2959
|
+
|
|
2960
|
+
|
|
2961
|
+
def _render_prepared(snap: ShareSnapshot, *, format: str, theme: str,
|
|
2962
|
+
branding: bool) -> str:
|
|
2963
|
+
"""Fragment + chrome for one already-prepared snapshot."""
|
|
2056
2964
|
if format == "md":
|
|
2057
2965
|
frag = _render_fragment(snap, format="md", palette=PALETTE_LIGHT, branding=branding)
|
|
2058
2966
|
return _wrap_document(frag, format="md", palette=PALETTE_LIGHT, snap=snap,
|