cctally 1.113.0 → 1.115.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 +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +6035 -722
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +400 -2
- package/bin/_cctally_dashboard.py +98 -20
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +285 -213
- package/bin/_cctally_db.py +1177 -23
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +572 -45
- package/bin/_cctally_five_hour.py +84 -33
- package/bin/_cctally_forecast.py +108 -15
- package/bin/_cctally_journal.py +3950 -116
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +227 -37
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +691 -149
- package/bin/_cctally_rederive.py +320 -41
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +83 -17
- package/bin/_cctally_statusline.py +6154 -517
- package/bin/_cctally_store.py +367 -53
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +61 -56
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +297 -49
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +106 -59
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +714 -69
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +231 -86
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +199 -9
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +319 -82
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +31 -1
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +19 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
package/bin/_lib_merge_gate.py
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
This is a reviewed component policy, not proof of every possible dependency.
|
|
4
4
|
Unknown paths and shared/infrastructure changes require the full estate. The
|
|
5
|
-
full
|
|
5
|
+
scheduled full run and the release run remain the cross-component backstop.
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import hashlib
|
|
11
|
+
import importlib.util
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
13
14
|
from pathlib import Path, PurePosixPath
|
|
@@ -26,6 +27,45 @@ PRESENTATION_FILES = frozenset(
|
|
|
26
27
|
)
|
|
27
28
|
SMOKE_FILES = ('tests/test_cli_smoke.py', 'tests/test_lib_json_envelope.py')
|
|
28
29
|
PRESENTATION_PYTEST = ('tests/test_dashboard_handler_static.py',)
|
|
30
|
+
# The reviewed memory publication set: both topic stores and the topic metadata.
|
|
31
|
+
# A private test pins it to the publication engine's own definition. Its
|
|
32
|
+
# configuration is NOT memory, so a change to it is ordinary task work.
|
|
33
|
+
MEMORY_PUBLICATION_SET = ('.claude-memory/**', '.agentmem/codex-memory/**', '.agentmem/topics.json')
|
|
34
|
+
MEMORY_CONFIGURATION = '.agentmem/config.json'
|
|
35
|
+
# Every test module that reads the memory corpus, beside the smoke files and
|
|
36
|
+
# the Doc lint harness: together they certify a memory-only candidate.
|
|
37
|
+
MEMORY_PYTEST = ('tests/test_cctally_memory.py', 'tests/test_agent_workflows.py',
|
|
38
|
+
'tests/test_session_workflow_texts.py')
|
|
39
|
+
# The capture publishes regular files only (a link is refused), so a memory
|
|
40
|
+
# path that is anything else on either side is not a memory publication.
|
|
41
|
+
REGULAR_MODES = ('100644', '100755')
|
|
42
|
+
# The instruction-text class (#894): agent-instruction prose whose every reader
|
|
43
|
+
# is one of INSTRUCTIONS_PYTEST. Markdown at any depth under these trees,
|
|
44
|
+
# nested CLAUDE.md/AGENTS.md included.
|
|
45
|
+
INSTRUCTION_TREES = ('.agent-workflows/skills/', '.claude/skills/', '.agents/skills/',
|
|
46
|
+
'.agent-workflows/contracts/', 'docs/superpowers/')
|
|
47
|
+
INSTRUCTION_NAMES = ('CLAUDE.md', 'AGENTS.md')
|
|
48
|
+
# Every nested instruction file outside the trees. A new one is a structural
|
|
49
|
+
# change to the instruction chain: its readers depend on where it lands, so it
|
|
50
|
+
# selects the full estate until a reviewed policy change lists it here.
|
|
51
|
+
NESTED_INSTRUCTION_FILES = ('bin/AGENTS.md', 'dashboard/AGENTS.md')
|
|
52
|
+
# The generator inputs of the root AGENTS.md (its configuration is not one).
|
|
53
|
+
INSTRUCTION_INPUTS = ('.agentmem/codex.md', '.agentmem/manifest.json')
|
|
54
|
+
# Every test module that reads, validates or scans an instruction path,
|
|
55
|
+
# beside the smoke files and the Doc lint harness.
|
|
56
|
+
INSTRUCTIONS_PYTEST = ('tests/test_agent_workflows.py', 'tests/test_session_workflow_texts.py',
|
|
57
|
+
'tests/test_cctally_memory.py', 'tests/test_session_issues.py',
|
|
58
|
+
'tests/test_session_provenance.py', 'tests/test_linux_matrix_gate.py',
|
|
59
|
+
'tests/test_release_internals.py', 'tests/test_quota_budget_error_harness.py',
|
|
60
|
+
'tests/test_session_policy.py', 'tests/test_lib_share.py',
|
|
61
|
+
'tests/test_dashboard_source_kernel.py', 'tests/test_share_privacy_detector.py',
|
|
62
|
+
'tests/test_fixture_cache.py')
|
|
63
|
+
# An instruction path is eligible only as a non-executable regular file.
|
|
64
|
+
INSTRUCTION_MODES = ('100644',)
|
|
65
|
+
# The two session execution plans are documentation: their only readers are
|
|
66
|
+
# people and the Doc lint harness, so a plan refresh takes the documentation
|
|
67
|
+
# focus. Exactly these two paths; any other docs/ Markdown is unclassified.
|
|
68
|
+
EXECUTION_PLAN_PATHS = ('docs/cctally-claude-execution-plan.md', 'docs/cctally-execution-plan.md')
|
|
29
69
|
|
|
30
70
|
|
|
31
71
|
def _git(repo, *args):
|
|
@@ -58,13 +98,113 @@ def basis(repo, base_rev, head='HEAD', *, working=True):
|
|
|
58
98
|
'paths': sorted(committed | dirty)}
|
|
59
99
|
|
|
60
100
|
|
|
101
|
+
def memory_publication_path(path):
|
|
102
|
+
"""Whether `path` is in the memory publication set (the engine's glob semantics)."""
|
|
103
|
+
path = path.strip('/')
|
|
104
|
+
for pattern in MEMORY_PUBLICATION_SET:
|
|
105
|
+
if pattern.endswith('/**'):
|
|
106
|
+
prefix = pattern[:-3]
|
|
107
|
+
if path == prefix or path.startswith(prefix + '/'):
|
|
108
|
+
return True
|
|
109
|
+
elif path == pattern:
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _tree_modes(repo, rev, paths):
|
|
115
|
+
"""{path: git mode} of the entries `paths` name in `rev` (absent paths are omitted)."""
|
|
116
|
+
out = _git(repo, '--literal-pathspecs', 'ls-tree', '-z', '--full-tree', rev, '--', *paths)
|
|
117
|
+
modes = {}
|
|
118
|
+
for record in out.split(b'\0'):
|
|
119
|
+
if record:
|
|
120
|
+
meta, name = record.split(b'\t', 1)
|
|
121
|
+
modes[name.decode('utf-8', errors='strict')] = meta.split()[0].decode()
|
|
122
|
+
return modes
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def irregular_memory_paths(repo, base, head_oid, paths, *, working=True):
|
|
126
|
+
"""The memory paths that are not a regular file (or absent) at the base, at the head,
|
|
127
|
+
or (with `working`) in the working tree: the file types the publication set does not define."""
|
|
128
|
+
irregular = set()
|
|
129
|
+
for rev in (base, head_oid):
|
|
130
|
+
irregular.update(name for name, mode in _tree_modes(repo, rev, paths).items()
|
|
131
|
+
if mode not in REGULAR_MODES)
|
|
132
|
+
if working:
|
|
133
|
+
for path in paths:
|
|
134
|
+
target = Path(repo) / path
|
|
135
|
+
if target.is_symlink() or (target.exists() and not target.is_file()):
|
|
136
|
+
irregular.add(path)
|
|
137
|
+
return sorted(irregular & set(paths))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _blob_prefixes(repo, rev, paths):
|
|
141
|
+
"""{path: its first two bytes} for the blobs `paths` name in `rev`, in one git call."""
|
|
142
|
+
if not paths:
|
|
143
|
+
return {}
|
|
144
|
+
request = b''.join(f'{rev}:{path}\n'.encode('utf-8') for path in paths)
|
|
145
|
+
proc = subprocess.run(['git', '--no-optional-locks', '-C', str(repo), 'cat-file', '--batch'],
|
|
146
|
+
input=request, capture_output=True)
|
|
147
|
+
if proc.returncode:
|
|
148
|
+
raise ValueError('git cat-file failed: ' + proc.stderr.decode(errors='replace').strip())
|
|
149
|
+
out, offset, prefixes = proc.stdout, 0, {}
|
|
150
|
+
for path in paths:
|
|
151
|
+
end = out.index(b'\n', offset)
|
|
152
|
+
header = out[offset:end].split()
|
|
153
|
+
if len(header) != 3 or header[1] != b'blob':
|
|
154
|
+
raise ValueError('git cat-file returned no blob for ' + path)
|
|
155
|
+
size = int(header[2])
|
|
156
|
+
prefixes[path] = out[end + 1:end + 1 + min(size, 2)]
|
|
157
|
+
offset = end + 1 + size + 1
|
|
158
|
+
return prefixes
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def ineligible_instruction_paths(repo, base, head_oid, paths, *, working=True):
|
|
162
|
+
"""The instruction paths that are not a non-executable regular file without a
|
|
163
|
+
`#!` first line at the base, at the head, or (with `working`) on disk. Absence
|
|
164
|
+
on any side is eligible. With `working=False` only the two commits are read."""
|
|
165
|
+
paths = sorted(p for p in paths if _sane(p) and _instruction_class(p) == 'instruction')
|
|
166
|
+
if not paths:
|
|
167
|
+
return []
|
|
168
|
+
ineligible = set()
|
|
169
|
+
for rev in (base, head_oid):
|
|
170
|
+
modes = _tree_modes(repo, rev, paths)
|
|
171
|
+
ineligible.update(name for name, mode in modes.items() if mode not in INSTRUCTION_MODES)
|
|
172
|
+
regular = sorted(name for name, mode in modes.items() if mode in INSTRUCTION_MODES)
|
|
173
|
+
ineligible.update(name for name, prefix in _blob_prefixes(repo, rev, regular).items()
|
|
174
|
+
if prefix == b'#!')
|
|
175
|
+
if working:
|
|
176
|
+
for path in paths:
|
|
177
|
+
target = Path(repo) / path
|
|
178
|
+
if target.is_symlink():
|
|
179
|
+
ineligible.add(path)
|
|
180
|
+
elif target.exists():
|
|
181
|
+
if not target.is_file() or target.stat().st_mode & 0o111:
|
|
182
|
+
ineligible.add(path)
|
|
183
|
+
else:
|
|
184
|
+
with target.open('rb') as handle:
|
|
185
|
+
if handle.read(2) == b'#!':
|
|
186
|
+
ineligible.add(path)
|
|
187
|
+
return sorted(ineligible & set(paths))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _memory_only(paths, irregular):
|
|
191
|
+
"""A memory-only candidate: every path in the publication set, each a regular file."""
|
|
192
|
+
return (irregular is not None and bool(paths) and not irregular
|
|
193
|
+
and all(memory_publication_path(p) and _sane(p) for p in paths))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _sane(path):
|
|
197
|
+
p = PurePosixPath(path)
|
|
198
|
+
return not (p.is_absolute() or '..' in p.parts or any(c.isspace() for c in path))
|
|
199
|
+
|
|
200
|
+
|
|
61
201
|
def policy_digest(repo):
|
|
62
202
|
return 'sha256:' + hashlib.sha256(
|
|
63
203
|
(Path(repo) / 'bin/_lib_merge_gate.py').read_bytes()).hexdigest()
|
|
64
204
|
|
|
65
205
|
|
|
66
206
|
def _document(path):
|
|
67
|
-
return path in ('README.md', 'CHANGELOG.md') or (
|
|
207
|
+
return path in ('README.md', 'CHANGELOG.md') or path in EXECUTION_PLAN_PATHS or (
|
|
68
208
|
path.startswith('docs/commands/') and path.endswith('.md'))
|
|
69
209
|
|
|
70
210
|
|
|
@@ -72,7 +212,32 @@ def _frontend_test(path):
|
|
|
72
212
|
return path.startswith('dashboard/web/src/') and path.endswith(('.test.ts', '.test.tsx'))
|
|
73
213
|
|
|
74
214
|
|
|
75
|
-
def
|
|
215
|
+
def _instruction_class(path):
|
|
216
|
+
"""'instruction' for an instruction path, a full-selection reason for an
|
|
217
|
+
instruction-file name at an unreviewed location, or None for any other path."""
|
|
218
|
+
if path.endswith('.md') and path.startswith(INSTRUCTION_TREES):
|
|
219
|
+
return 'instruction'
|
|
220
|
+
if PurePosixPath(path).name in INSTRUCTION_NAMES:
|
|
221
|
+
if path in INSTRUCTION_NAMES or path in NESTED_INSTRUCTION_FILES:
|
|
222
|
+
return 'instruction'
|
|
223
|
+
return 'unreviewed nested instruction file: ' + path
|
|
224
|
+
if path in INSTRUCTION_INPUTS:
|
|
225
|
+
return 'instruction'
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def select(repo, paths, *, irregular=None, instruction_evidence=None):
|
|
230
|
+
"""The reviewed selection of `paths`.
|
|
231
|
+
|
|
232
|
+
`irregular` is the list of memory paths that are not regular files
|
|
233
|
+
(`irregular_memory_paths`); only a caller that computed it can have a
|
|
234
|
+
candidate selected as memory-only. Any other candidate is selected over its
|
|
235
|
+
whole diff, where a memory path is unclassified.
|
|
236
|
+
|
|
237
|
+
`instruction_evidence` is the list of instruction paths that are not
|
|
238
|
+
non-executable regular files (`ineligible_instruction_paths`); only a caller
|
|
239
|
+
that computed it can have an instruction path selected focused.
|
|
240
|
+
"""
|
|
76
241
|
repo = Path(repo)
|
|
77
242
|
manifest = json.loads((repo / 'tests/authoritative-test-manifest.json').read_text())
|
|
78
243
|
private = (repo / '.mirror-allowlist').exists()
|
|
@@ -82,10 +247,28 @@ def select(repo, paths):
|
|
|
82
247
|
raise ValueError('invalid harness manifest')
|
|
83
248
|
paths = sorted(set(paths))
|
|
84
249
|
reasons, components = [], set()
|
|
85
|
-
|
|
250
|
+
memory = _memory_only(paths, irregular)
|
|
251
|
+
if memory:
|
|
252
|
+
components.add('memory')
|
|
253
|
+
for path in ([] if memory else paths):
|
|
86
254
|
p = PurePosixPath(path)
|
|
87
|
-
if
|
|
255
|
+
if path in (irregular or ()):
|
|
256
|
+
reasons.append('memory path that is not a regular file: ' + path)
|
|
257
|
+
elif p.is_absolute() or '..' in p.parts or any(c.isspace() for c in path):
|
|
88
258
|
reasons.append('unclassified path: ' + path)
|
|
259
|
+
elif '__pycache__' in p.parts:
|
|
260
|
+
# Terminal: the skill comparison never reads cache content, so no
|
|
261
|
+
# class may admit it (the memory-only shortcut is decided above).
|
|
262
|
+
reasons.append('runtime cache path: ' + path)
|
|
263
|
+
elif (instruction := _instruction_class(path)) is not None:
|
|
264
|
+
if instruction != 'instruction':
|
|
265
|
+
reasons.append(instruction)
|
|
266
|
+
elif instruction_evidence is None:
|
|
267
|
+
reasons.append('instruction path without file evidence: ' + path)
|
|
268
|
+
elif path in instruction_evidence:
|
|
269
|
+
reasons.append('instruction path that is not a non-executable regular file: ' + path)
|
|
270
|
+
else:
|
|
271
|
+
components.add('instructions')
|
|
89
272
|
elif _document(path):
|
|
90
273
|
components.add('documentation')
|
|
91
274
|
elif path in PRESENTATION_FILES or _frontend_test(path):
|
|
@@ -96,11 +279,17 @@ def select(repo, paths):
|
|
|
96
279
|
components.add('presentation')
|
|
97
280
|
else:
|
|
98
281
|
reasons.append('shared, high-risk, or unclassified path: ' + path)
|
|
282
|
+
if 'instructions' in components and 'presentation' in components:
|
|
283
|
+
reasons.append('instruction text and presentation are not certified together')
|
|
99
284
|
harnesses = {'doc-lint'}
|
|
100
285
|
files = set(SMOKE_FILES)
|
|
101
286
|
if 'presentation' in components:
|
|
102
287
|
harnesses.add('frontend')
|
|
103
288
|
files.update(PRESENTATION_PYTEST)
|
|
289
|
+
if 'memory' in components:
|
|
290
|
+
files.update(MEMORY_PYTEST)
|
|
291
|
+
if 'instructions' in components:
|
|
292
|
+
files.update(INSTRUCTIONS_PYTEST)
|
|
104
293
|
if not harnesses <= set(names):
|
|
105
294
|
reasons.append('required merge harness absent from manifest')
|
|
106
295
|
if any(not (repo / p).is_file() for p in files):
|
|
@@ -113,19 +302,32 @@ def select(repo, paths):
|
|
|
113
302
|
'omittedHarnesses': [n for n in names if not full and n not in harnesses],
|
|
114
303
|
'selectedPytestFiles': [] if full else sorted(files),
|
|
115
304
|
'components': sorted(components), 'reasons': reasons,
|
|
116
|
-
'policyDigest': policy_digest(repo),
|
|
305
|
+
'policyDigest': policy_digest(repo),
|
|
306
|
+
'maxWallSeconds': MAX_SECONDS,
|
|
117
307
|
}
|
|
118
308
|
|
|
119
309
|
|
|
120
310
|
def plan(repo, base_rev, head='HEAD', *, working=True):
|
|
121
311
|
b = basis(repo, base_rev, head, working=working)
|
|
122
|
-
|
|
312
|
+
irregular = None
|
|
313
|
+
if b['paths'] and all(memory_publication_path(p) for p in b['paths']):
|
|
314
|
+
irregular = irregular_memory_paths(repo, b['baseOid'], b['headOid'], b['paths'], working=working)
|
|
315
|
+
evidence = None
|
|
316
|
+
if any(_sane(p) and _instruction_class(p) == 'instruction' for p in b['paths']):
|
|
317
|
+
evidence = ineligible_instruction_paths(repo, b['baseOid'], b['headOid'], b['paths'],
|
|
318
|
+
working=working)
|
|
319
|
+
result = select(repo, b['paths'], irregular=irregular, instruction_evidence=evidence)
|
|
123
320
|
result['mergeBasis'] = b
|
|
124
321
|
return result
|
|
125
322
|
|
|
126
323
|
|
|
127
|
-
def filter_legs(plan_record, directory):
|
|
128
|
-
"""Narrow both expected-node legs to selected files; retain serial ownership.
|
|
324
|
+
def filter_legs(plan_record, directory, quarantined=()):
|
|
325
|
+
"""Narrow both expected-node legs to selected files; retain serial ownership.
|
|
326
|
+
|
|
327
|
+
bin/cctally-test-all applies tests/quarantine.json to the legs first, so a
|
|
328
|
+
selected file whose every node is quarantined has none left. Such a file
|
|
329
|
+
(one that ``quarantined``, the list's node ids, names) contributes no
|
|
330
|
+
targets; any other selected file without a recorded node refuses."""
|
|
129
331
|
directory = Path(directory)
|
|
130
332
|
selected = set(plan_record['selectedPytestFiles'])
|
|
131
333
|
found = set()
|
|
@@ -134,78 +336,20 @@ def filter_legs(plan_record, directory):
|
|
|
134
336
|
nodes = [n for n in path.read_text().splitlines() if n.split('::')[0] in selected]
|
|
135
337
|
found.update(n.split('::')[0] for n in nodes)
|
|
136
338
|
path.write_text(''.join(n + '\n' for n in nodes))
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def background_health(rows):
|
|
142
|
-
"""Newest completed full verification wins; running work hides nothing."""
|
|
143
|
-
for row in rows:
|
|
144
|
-
if row.get('head_branch') != 'main' or row.get('status') != 'completed':
|
|
145
|
-
continue
|
|
146
|
-
if row.get('event') not in ('schedule', 'workflow_dispatch') and row.get('fullSuite') is not True:
|
|
147
|
-
continue
|
|
148
|
-
passed = row.get('conclusion') == 'success'
|
|
149
|
-
return {'state': 'passed' if passed else 'failed', 'allowFocused': passed,
|
|
150
|
-
'runId': row.get('id'), 'headSha': row.get('head_sha')}
|
|
151
|
-
return {'state': 'unavailable', 'allowFocused': False, 'runId': None, 'headSha': None}
|
|
339
|
+
absent = selected - found - {node.split('::')[0] for node in quarantined}
|
|
340
|
+
if absent:
|
|
341
|
+
raise ValueError('selected files absent from recorded pytest estate: ' + ', '.join(sorted(absent)))
|
|
152
342
|
|
|
153
343
|
|
|
154
|
-
def
|
|
155
|
-
"""
|
|
344
|
+
def quarantine_ids(repo):
|
|
345
|
+
"""The pytest node ids tests/quarantine.json lists; none when the tree carries no list.
|
|
156
346
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"""
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if process.returncode:
|
|
164
|
-
raise ValueError('GitHub full-verification evidence is unavailable')
|
|
165
|
-
return json.loads(process.stdout)
|
|
166
|
-
try:
|
|
167
|
-
name = gh('repo', 'view', '--json', 'nameWithOwner')['nameWithOwner']
|
|
168
|
-
rows = gh('api', f'repos/{name}/actions/workflows/ci.yml/runs?branch=main&per_page=20')['workflow_runs']
|
|
169
|
-
for row in rows:
|
|
170
|
-
if row.get('status') != 'completed':
|
|
171
|
-
# GitHub reuses the run ID on retry: the earlier failed
|
|
172
|
-
# attempt disappears from this listing while the retry runs.
|
|
173
|
-
# Do not let that mutation reveal an older successful run.
|
|
174
|
-
if row.get('run_attempt', 1) != 1:
|
|
175
|
-
return background_health([])
|
|
176
|
-
continue
|
|
177
|
-
jobs = gh('api', f'repos/{name}/actions/runs/{row["id"]}/jobs?per_page=100')['jobs']
|
|
178
|
-
full = next((j for j in jobs if j.get('name') == 'test-macos'
|
|
179
|
-
and j.get('status') == 'completed'
|
|
180
|
-
and j.get('conclusion') != 'skipped'), None)
|
|
181
|
-
if full is not None:
|
|
182
|
-
record = dict(row, fullSuite=True, conclusion=full.get('conclusion'))
|
|
183
|
-
return background_health([record])
|
|
184
|
-
if row.get('event') in ('schedule', 'workflow_dispatch'):
|
|
185
|
-
# A scheduled/manual attempt must actually run the full job.
|
|
186
|
-
# Only receipt-discharged ordinary pushes may be skipped while
|
|
187
|
-
# looking for the last full verification.
|
|
188
|
-
return background_health([])
|
|
189
|
-
# ci.yml can skip its declared full job on a successful private
|
|
190
|
-
# main push only through a successful release/receipt gate. The
|
|
191
|
-
# skipped job plus successful workflow is evidence of discharge;
|
|
192
|
-
# a merely successful classifier job alone is not (it may emit
|
|
193
|
-
# discharged=false). Missing jobs, cancellation, and failures
|
|
194
|
-
# must not silently inherit an older pass.
|
|
195
|
-
discharged = (
|
|
196
|
-
row.get('event') == 'push' and row.get('conclusion') == 'success'
|
|
197
|
-
and any(j.get('name') == 'test-macos'
|
|
198
|
-
and j.get('status') == 'completed'
|
|
199
|
-
and j.get('conclusion') == 'skipped' for j in jobs)
|
|
200
|
-
and any(j.get('name') in ('receipt-gate', 'release-stamp-gate')
|
|
201
|
-
and j.get('status') == 'completed'
|
|
202
|
-
and j.get('conclusion') == 'success' for j in jobs)
|
|
203
|
-
)
|
|
204
|
-
if not discharged:
|
|
205
|
-
return background_health([])
|
|
206
|
-
return background_health([])
|
|
207
|
-
except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired):
|
|
208
|
-
return background_health([])
|
|
347
|
+
Parsed by the shared bin/_lib_quarantine.py; harness cases are not pytest
|
|
348
|
+
nodes and are left to their harness. bin/cctally-test-all validated the
|
|
349
|
+
list at admission, before this runs."""
|
|
350
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
351
|
+
import _lib_quarantine
|
|
352
|
+
return _lib_quarantine.pytest_ids(_lib_quarantine.load(repo))
|
|
209
353
|
|
|
210
354
|
|
|
211
355
|
def supervise(command, seconds):
|
|
@@ -264,7 +408,6 @@ def main(argv=None):
|
|
|
264
408
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
265
409
|
parser.add_argument('--repo', default=str(Path(__file__).resolve().parents[1]))
|
|
266
410
|
parser.add_argument('--base')
|
|
267
|
-
parser.add_argument('--health', action='store_true')
|
|
268
411
|
parser.add_argument('--required-mode', action='store_true')
|
|
269
412
|
parser.add_argument('--shell', action='store_true')
|
|
270
413
|
parser.add_argument('--filter-legs')
|
|
@@ -275,18 +418,20 @@ def main(argv=None):
|
|
|
275
418
|
if len(args.deadline) < 2:
|
|
276
419
|
parser.error('deadline requires seconds and a command')
|
|
277
420
|
return supervise(args.deadline[1:], int(args.deadline[0]))
|
|
278
|
-
if args.health:
|
|
279
|
-
health = read_background_health(args.repo)
|
|
280
|
-
print(json.dumps(health, sort_keys=True))
|
|
281
|
-
return 0 if health['allowFocused'] else 3
|
|
282
421
|
if not args.base:
|
|
283
|
-
parser.error('--base is required
|
|
422
|
+
parser.error('--base is required')
|
|
284
423
|
try:
|
|
285
424
|
result = plan(args.repo, args.base)
|
|
286
425
|
if args.required_mode:
|
|
287
|
-
|
|
426
|
+
# `<mode> <reason>`: the wrapper decides on the mode alone; the
|
|
427
|
+
# reason names the focused components for whoever reads the log.
|
|
428
|
+
if result['mode'] == 'full':
|
|
429
|
+
reason = 'reviewed-selection'
|
|
430
|
+
else:
|
|
431
|
+
reason = '+'.join(result['components']) or 'none'
|
|
432
|
+
print(f"{result['mode']} {reason}")
|
|
288
433
|
elif args.filter_legs:
|
|
289
|
-
filter_legs(result, args.filter_legs)
|
|
434
|
+
filter_legs(result, args.filter_legs, quarantined=quarantine_ids(args.repo))
|
|
290
435
|
elif args.shell:
|
|
291
436
|
print('mode\t' + result['mode'])
|
|
292
437
|
print('harnesses\t' + ' '.join(result['selectedHarnesses']))
|
|
@@ -246,6 +246,12 @@ def _adjacent_pairs(regimes) -> dict:
|
|
|
246
246
|
`now` with `status: "stale"` and appends a fresh regime starting at the
|
|
247
247
|
analysis start, so the two are neither adjacent nor a rate transition. The
|
|
248
248
|
status check is the second, independent guard.
|
|
249
|
+
|
|
250
|
+
#978 spec 4.5: a pair also requires one `fingerprint` and one
|
|
251
|
+
`coefficientEra`. Units-per-point from two eras measure two different
|
|
252
|
+
weightings, so their difference is a coefficient change and never a
|
|
253
|
+
metering-rate change. The comparison uses `.get()`, so a legacy pair in
|
|
254
|
+
which neither regime names an era keeps its treatment.
|
|
249
255
|
"""
|
|
250
256
|
closed: dict = {}
|
|
251
257
|
for regime in regimes:
|
|
@@ -266,6 +272,10 @@ def _adjacent_pairs(regimes) -> dict:
|
|
|
266
272
|
predecessor = closed.get(start)
|
|
267
273
|
if predecessor is None or predecessor is regime:
|
|
268
274
|
continue
|
|
275
|
+
if predecessor.get("fingerprint") != regime.get("fingerprint") \
|
|
276
|
+
or predecessor.get("coefficientEra") \
|
|
277
|
+
!= regime.get("coefficientEra"):
|
|
278
|
+
continue
|
|
269
279
|
pairs[start] = (predecessor, regime)
|
|
270
280
|
return pairs
|
|
271
281
|
|