loki-mode 9.3.0 → 9.4.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/tools/cost-attribute.py +394 -0
- package/tools/gate-explain.py +290 -0
- package/tools/gate-log.py +422 -0
- package/tools/receipt-export.py +348 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One gate verdict is a fact. A hundred of them is a pattern. Keep them.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. tools/ci-gate.py decides one run correctly and exits 0/1/2,
|
|
5
|
+
and tools/gate-report.py renders that one run for a human. Both are amnesiac by
|
|
6
|
+
design: they answer "is THIS merge safe" and then forget. But the questions an
|
|
7
|
+
engineer actually asks about a merge gate are historical ones --
|
|
8
|
+
|
|
9
|
+
which policy blocks us most often, and is it getting better or worse?
|
|
10
|
+
how long has the receipt axis been blind?
|
|
11
|
+
|
|
12
|
+
-- and nothing in this repo could answer them, because no verdict was ever
|
|
13
|
+
written down. So a durable append-only JSONL log, and a report over it.
|
|
14
|
+
|
|
15
|
+
THE RULE THIS FILE INHERITS, and the reason it is not just a counter:
|
|
16
|
+
|
|
17
|
+
A POLICY THAT COULD NOT BE EVALUATED HAS NOT PASSED.
|
|
18
|
+
|
|
19
|
+
An aggregator is where that rule dies, and it dies by arithmetic rather than by
|
|
20
|
+
argument. Two lines of code make it die:
|
|
21
|
+
|
|
22
|
+
passes = total - failures # folds UNEVALUABLE into pass
|
|
23
|
+
rate = passes / total # a "94% pass rate" built on blindness
|
|
24
|
+
|
|
25
|
+
Both read as reasonable. Both convert "we were blind on the receipt axis for
|
|
26
|
+
three weeks" into a healthy-looking green number, and the longer the blindness
|
|
27
|
+
lasts the healthier the number looks, because a blind axis never fails. So
|
|
28
|
+
UNEVALUABLE is its OWN category here, counted and printed beside PASS and FAIL,
|
|
29
|
+
and there is no derived pass-rate anywhere in this file. Three counts, stated.
|
|
30
|
+
|
|
31
|
+
A CORRUPT LINE IS DATA. The obvious loop skips lines that do not parse, and a
|
|
32
|
+
skipped line is a verdict that silently left the record. A log half-eaten by a
|
|
33
|
+
crashed writer would then report a clean history of whatever survived. Corrupt
|
|
34
|
+
lines are counted and reported as their own category, so "12 records, 4 of them
|
|
35
|
+
unreadable" can never render as "12 records".
|
|
36
|
+
|
|
37
|
+
AN EMPTY LOG IS NOT A CLEAN HISTORY. `report` on zero records exits non-zero
|
|
38
|
+
and says UNKNOWN. "No blocks recorded" and "never blocked" are opposite facts
|
|
39
|
+
about the world, and the first one is what an empty file means. Absent is not
|
|
40
|
+
zero -- this repo has paid for that inversion on more than a dozen surfaces.
|
|
41
|
+
|
|
42
|
+
MALFORMED STDIN IS AN ERROR, NEVER AN ASSUMED PASS. `record` reading garbage
|
|
43
|
+
writes nothing and exits 66. The one thing it must never do is invent a PASS
|
|
44
|
+
row, because the log is the evidence every later report is built from, and a
|
|
45
|
+
fabricated row is indistinguishable from a real one forever after.
|
|
46
|
+
|
|
47
|
+
But note the SPLIT, because over-strictness here is its own dishonesty: input
|
|
48
|
+
that is not JSON at all is an error and is refused, while a well-formed verdict
|
|
49
|
+
carrying a state word we do not recognise is RECORDED as UNEVALUABLE. The gate
|
|
50
|
+
did run and did report something; we simply cannot read its verdict, and that
|
|
51
|
+
is exactly what UNEVALUABLE means. Dropping it would delete the evidence that
|
|
52
|
+
our own vocabulary drifted.
|
|
53
|
+
|
|
54
|
+
WHY APPEND-ONLY, SINGLE WRITE. One `open(..., "a")` and one `write()` of one
|
|
55
|
+
line that already ends in a newline. No read-modify-write, so two CI jobs
|
|
56
|
+
finishing together cannot lose each other's verdict, and nothing this tool does
|
|
57
|
+
can edit or remove a verdict already recorded. A log a tool can rewrite is not
|
|
58
|
+
evidence.
|
|
59
|
+
|
|
60
|
+
WHAT IS DELIBERATELY NOT HERE. No cost figure. ci-gate's JSON carries no
|
|
61
|
+
measured cost, and recovering one by parsing the dollar amount out of
|
|
62
|
+
cost-guard's human-readable reason string would restate a predicate that
|
|
63
|
+
autonomy/lib/efficiency_cost.py owns -- the exact drift this repo fixed across
|
|
64
|
+
four surfaces. No cost surface is better than a re-derived one.
|
|
65
|
+
|
|
66
|
+
Usage:
|
|
67
|
+
tools/ci-gate.py <ws> --max-usd 5 --json | tools/gate-log.py record
|
|
68
|
+
tools/gate-log.py report [--json]
|
|
69
|
+
|
|
70
|
+
Exit: record 0 on write, 66 on unusable stdin. report 0 all-pass, 1 a FAIL was
|
|
71
|
+
recorded, 2 an UNEVALUABLE or corrupt record (blind outranks failed), 3 the log
|
|
72
|
+
exists but holds no records, 66 the log file does not exist, 64 usage error.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
import argparse
|
|
76
|
+
import datetime
|
|
77
|
+
import json
|
|
78
|
+
import os
|
|
79
|
+
import sys
|
|
80
|
+
|
|
81
|
+
PASSED, FAILED, COULD_NOT_CHECK, NOTHING, USAGE, MISSING = 0, 1, 2, 3, 64, 66
|
|
82
|
+
|
|
83
|
+
DEFAULT_LOG = os.path.join(".loki", "gate-log.jsonl")
|
|
84
|
+
|
|
85
|
+
# THE ONE MAPPING from a gate's verdict word to a bucket in the report. Three
|
|
86
|
+
# buckets, never two. Folding "UNEVALUABLE" in with "PASS" here is the entire
|
|
87
|
+
# defect this file exists to prevent, and it is a one-word edit, which is why
|
|
88
|
+
# tests/test_gate_log.py mutates precisely this line.
|
|
89
|
+
_CATEGORY = {"PASS": "pass", "FAIL": "fail", "UNEVALUABLE": "unevaluable"}
|
|
90
|
+
|
|
91
|
+
# Corrupt is not a verdict a gate can emit; it is what a damaged log line
|
|
92
|
+
# becomes. Kept out of _CATEGORY so no state word can ever map into it.
|
|
93
|
+
_CORRUPT = "corrupt"
|
|
94
|
+
|
|
95
|
+
_ORDER = ["pass", "fail", "unevaluable", _CORRUPT]
|
|
96
|
+
|
|
97
|
+
# Weakest link, same precedence ci-gate uses: blind outranks failed. An
|
|
98
|
+
# operator who sees 1, fixes the cost and re-runs is still blind on the dead
|
|
99
|
+
# axis, so the exit code must surface the blindness first.
|
|
100
|
+
_EXIT_FOR = {"pass": PASSED, "fail": FAILED,
|
|
101
|
+
"unevaluable": COULD_NOT_CHECK, _CORRUPT: COULD_NOT_CHECK}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Parser(argparse.ArgumentParser):
|
|
105
|
+
"""argparse exits 2 on a usage error. Here 2 means "could not check".
|
|
106
|
+
|
|
107
|
+
A mistyped flag would otherwise be indistinguishable from a gate reporting
|
|
108
|
+
that it was blind, and a CI job branching on the code would treat an
|
|
109
|
+
operator's typo as a real finding about the merge. 64 is the convention.
|
|
110
|
+
Subparsers inherit this class from the top-level parser, so `record
|
|
111
|
+
--bogus` lands on 64 too.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def error(self, message):
|
|
115
|
+
self.print_usage(sys.stderr)
|
|
116
|
+
sys.stderr.write("gate-log: %s\n" % message)
|
|
117
|
+
raise SystemExit(USAGE)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def classify(verdict):
|
|
121
|
+
"""Bucket ONE gate verdict. Anything unrecognised is unevaluable.
|
|
122
|
+
|
|
123
|
+
Never returns "pass" by default. A default of pass is how an aggregator
|
|
124
|
+
launders every shape it did not anticipate into green, and the shapes it
|
|
125
|
+
did not anticipate are precisely the broken ones.
|
|
126
|
+
"""
|
|
127
|
+
if not isinstance(verdict, dict):
|
|
128
|
+
return _CATEGORY["UNEVALUABLE"]
|
|
129
|
+
state = verdict.get("state")
|
|
130
|
+
if isinstance(state, str) and state.upper() in _CATEGORY:
|
|
131
|
+
return _CATEGORY[state.upper()]
|
|
132
|
+
return _CATEGORY["UNEVALUABLE"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def failing_policies(verdict):
|
|
136
|
+
"""Policy names this verdict recorded as FAIL. Only FAIL, never blind ones.
|
|
137
|
+
|
|
138
|
+
An unevaluable policy is not a failing policy: naming it in the
|
|
139
|
+
most-failing tally would send an engineer to fix a rule that never fired,
|
|
140
|
+
while the real problem is that its instrumentation is dead. The unevaluable
|
|
141
|
+
tally is reported separately for that reason.
|
|
142
|
+
"""
|
|
143
|
+
rows = verdict.get("policies") if isinstance(verdict, dict) else None
|
|
144
|
+
out = []
|
|
145
|
+
if isinstance(rows, list):
|
|
146
|
+
for row in rows:
|
|
147
|
+
if not isinstance(row, dict):
|
|
148
|
+
continue
|
|
149
|
+
state = row.get("state")
|
|
150
|
+
if isinstance(state, str) and state.upper() == "FAIL":
|
|
151
|
+
out.append(str(row.get("policy") or "?"))
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _entry(verdict):
|
|
156
|
+
"""The line that gets appended. Timestamp ours, verdict theirs, verbatim.
|
|
157
|
+
|
|
158
|
+
The raw verdict is embedded whole rather than summarised, so a later reader
|
|
159
|
+
who needs a field this version never thought about can still recover it.
|
|
160
|
+
Summarising at write time is a one-way loss.
|
|
161
|
+
"""
|
|
162
|
+
return {
|
|
163
|
+
"recorded_at": datetime.datetime.now(
|
|
164
|
+
datetime.timezone.utc).replace(microsecond=0).isoformat(),
|
|
165
|
+
"category": classify(verdict),
|
|
166
|
+
"failing_policies": failing_policies(verdict),
|
|
167
|
+
"verdict": verdict,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def append_record(path, verdict):
|
|
172
|
+
"""One open, one write, one line. No read-modify-write, ever."""
|
|
173
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
174
|
+
if parent and not os.path.isdir(parent):
|
|
175
|
+
os.makedirs(parent, exist_ok=True)
|
|
176
|
+
line = json.dumps(_entry(verdict), sort_keys=True) + "\n"
|
|
177
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
178
|
+
fh.write(line)
|
|
179
|
+
return line
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _io_error(action, path, exc):
|
|
183
|
+
"""An unreachable log is "could not check", never "checked and failed".
|
|
184
|
+
|
|
185
|
+
Without this an OSError escapes as a traceback and Python exits 1, which in
|
|
186
|
+
this convention claims the gate was evaluated and FAILED. A permission
|
|
187
|
+
error is not a finding about a merge.
|
|
188
|
+
"""
|
|
189
|
+
sys.stderr.write("gate-log: could not %s %s: %s\n" % (action, path, exc))
|
|
190
|
+
return COULD_NOT_CHECK
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def read_log(path):
|
|
194
|
+
"""Every line, corrupt ones INCLUDED as their own category.
|
|
195
|
+
|
|
196
|
+
Returns (entries, corrupt_count). A corrupt line that is merely skipped is
|
|
197
|
+
a verdict deleted from the record by the reader, and the resulting report
|
|
198
|
+
describes a history that never happened.
|
|
199
|
+
"""
|
|
200
|
+
entries, corrupt = [], 0
|
|
201
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
202
|
+
for raw in fh:
|
|
203
|
+
if not raw.strip():
|
|
204
|
+
continue # a trailing newline is not a damaged record
|
|
205
|
+
try:
|
|
206
|
+
entry = json.loads(raw)
|
|
207
|
+
except ValueError:
|
|
208
|
+
corrupt += 1
|
|
209
|
+
continue
|
|
210
|
+
if not isinstance(entry, dict):
|
|
211
|
+
corrupt += 1
|
|
212
|
+
continue
|
|
213
|
+
entries.append(entry)
|
|
214
|
+
return entries, corrupt
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _category_of(entry):
|
|
218
|
+
"""An entry's bucket, re-derived from its verdict if it lacks one.
|
|
219
|
+
|
|
220
|
+
Trusting a stored "category" blindly would let a hand-edited log assert
|
|
221
|
+
anything; falling back to the embedded verdict keeps the raw evidence
|
|
222
|
+
authoritative. An entry with neither is unevaluable, not a pass.
|
|
223
|
+
"""
|
|
224
|
+
stored = entry.get("category")
|
|
225
|
+
# Deliberately _CATEGORY.values() and not _ORDER: "corrupt" is in _ORDER
|
|
226
|
+
# but is not a verdict any gate can emit. Honouring a stored "corrupt"
|
|
227
|
+
# would add to the count of lines this reader actually failed to parse,
|
|
228
|
+
# and the buckets would then no longer sum to the record total.
|
|
229
|
+
if isinstance(stored, str) and stored in _CATEGORY.values():
|
|
230
|
+
return stored
|
|
231
|
+
return classify(entry.get("verdict"))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def summarize(entries, corrupt):
|
|
235
|
+
"""Counts, the most frequently failing policy, and the trend.
|
|
236
|
+
|
|
237
|
+
Every bucket is pre-seeded to 0, so a MEASURED zero survives as 0 and is
|
|
238
|
+
reported as 0. That is not cosmetic: "fail: 0" over 40 records is a real
|
|
239
|
+
finding, and it must not be confused with the UNKNOWN this returns when
|
|
240
|
+
there are no records at all.
|
|
241
|
+
"""
|
|
242
|
+
counts = dict((name, 0) for name in _ORDER)
|
|
243
|
+
counts[_CORRUPT] = corrupt
|
|
244
|
+
policy_hits = {}
|
|
245
|
+
sequence = []
|
|
246
|
+
for entry in entries:
|
|
247
|
+
category = _category_of(entry)
|
|
248
|
+
counts[category] = counts.get(category, 0) + 1
|
|
249
|
+
sequence.append(category)
|
|
250
|
+
for name in failing_policies(entry.get("verdict")):
|
|
251
|
+
policy_hits[name] = policy_hits.get(name, 0) + 1
|
|
252
|
+
|
|
253
|
+
total = len(entries) + corrupt
|
|
254
|
+
return {
|
|
255
|
+
"records": total,
|
|
256
|
+
"readable": len(entries),
|
|
257
|
+
"counts": counts,
|
|
258
|
+
"top_failing_policy": _top_policy(policy_hits),
|
|
259
|
+
"policy_failures": policy_hits,
|
|
260
|
+
"trend": _trend(sequence),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _top_policy(policy_hits):
|
|
265
|
+
"""The most frequently failing policy, or a MEASURED "none".
|
|
266
|
+
|
|
267
|
+
None means unknown; the string "none" with zero failures is a measurement
|
|
268
|
+
-- we read the records and no policy failed. Collapsing those two would
|
|
269
|
+
make an empty log and a clean history read identically.
|
|
270
|
+
"""
|
|
271
|
+
if not policy_hits:
|
|
272
|
+
return None
|
|
273
|
+
best = max(sorted(policy_hits), key=lambda name: policy_hits[name])
|
|
274
|
+
return {"policy": best, "failures": policy_hits[best]}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _trend(sequence):
|
|
278
|
+
"""Healthier, worse, or steady over the two halves. UNKNOWN under 2.
|
|
279
|
+
|
|
280
|
+
A single record has no trend. Reporting "steady" from one data point is an
|
|
281
|
+
invented measurement, so this returns None and the report prints UNKNOWN.
|
|
282
|
+
"Not blocked" counts pass only: an unevaluable half is not an improving
|
|
283
|
+
half, which is the same folding error the counts refuse to make.
|
|
284
|
+
"""
|
|
285
|
+
if len(sequence) < 2:
|
|
286
|
+
return None
|
|
287
|
+
half = len(sequence) // 2
|
|
288
|
+
older, newer = sequence[:half], sequence[half:]
|
|
289
|
+
before = sum(1 for c in older if c == "pass") / float(len(older))
|
|
290
|
+
after = sum(1 for c in newer if c == "pass") / float(len(newer))
|
|
291
|
+
if after > before:
|
|
292
|
+
direction = "improving"
|
|
293
|
+
elif after < before:
|
|
294
|
+
direction = "worsening"
|
|
295
|
+
else:
|
|
296
|
+
direction = "steady"
|
|
297
|
+
return {"direction": direction, "older_pass_rate": round(before, 3),
|
|
298
|
+
"newer_pass_rate": round(after, 3),
|
|
299
|
+
"window": [len(older), len(newer)]}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def render(summary):
|
|
303
|
+
counts = summary["counts"]
|
|
304
|
+
lines = ["gate-log: %d record(s)" % summary["records"], ""]
|
|
305
|
+
for name in _ORDER:
|
|
306
|
+
label = name if name != "unevaluable" \
|
|
307
|
+
else "unevaluable (NOT a pass)"
|
|
308
|
+
lines.append(" %-26s %d" % (label, counts.get(name, 0)))
|
|
309
|
+
lines.append("")
|
|
310
|
+
top = summary["top_failing_policy"]
|
|
311
|
+
if summary["readable"] == 0:
|
|
312
|
+
# Every line was unreadable. "no policy failed" would be a measurement
|
|
313
|
+
# we never took: we read nothing, so we know nothing about failures.
|
|
314
|
+
lines.append("most failing policy: UNKNOWN -- no readable record")
|
|
315
|
+
elif top is None:
|
|
316
|
+
lines.append("most failing policy: none -- no FAIL in %d readable "
|
|
317
|
+
"record(s)" % summary["readable"])
|
|
318
|
+
else:
|
|
319
|
+
lines.append("most failing policy: %s (%d failure(s))"
|
|
320
|
+
% (top["policy"], top["failures"]))
|
|
321
|
+
trend = summary["trend"]
|
|
322
|
+
if trend is None:
|
|
323
|
+
lines.append("trend: UNKNOWN -- fewer than 2 readable records (%d)"
|
|
324
|
+
% summary["readable"])
|
|
325
|
+
else:
|
|
326
|
+
lines.append("trend: %s (pass rate %.0f%% -> %.0f%% over %d then %d "
|
|
327
|
+
"record(s))"
|
|
328
|
+
% (trend["direction"], trend["older_pass_rate"] * 100,
|
|
329
|
+
trend["newer_pass_rate"] * 100,
|
|
330
|
+
trend["window"][0], trend["window"][1]))
|
|
331
|
+
return "\n".join(lines)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def exit_code(summary):
|
|
335
|
+
"""Weakest link over the whole history. Blind and corrupt outrank failed."""
|
|
336
|
+
worst = PASSED
|
|
337
|
+
counts = summary["counts"]
|
|
338
|
+
for name in _ORDER:
|
|
339
|
+
if counts.get(name, 0):
|
|
340
|
+
worst = max(worst, _EXIT_FOR[name])
|
|
341
|
+
return worst
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _cmd_record(args):
|
|
345
|
+
raw = sys.stdin.read()
|
|
346
|
+
if not raw.strip():
|
|
347
|
+
sys.stderr.write(
|
|
348
|
+
"gate-log: empty stdin -- expected a ci-gate --json verdict. "
|
|
349
|
+
"Nothing to record is not a pass, so nothing was written.\n")
|
|
350
|
+
return MISSING
|
|
351
|
+
try:
|
|
352
|
+
verdict = json.loads(raw)
|
|
353
|
+
except ValueError as exc:
|
|
354
|
+
sys.stderr.write(
|
|
355
|
+
"gate-log: could not parse the gate verdict (%s). Refusing to "
|
|
356
|
+
"record: an invented row is indistinguishable from a real one.\n"
|
|
357
|
+
% exc)
|
|
358
|
+
return MISSING
|
|
359
|
+
if not isinstance(verdict, dict):
|
|
360
|
+
sys.stderr.write(
|
|
361
|
+
"gate-log: expected a JSON object from ci-gate, got %s. Nothing "
|
|
362
|
+
"was written.\n" % type(verdict).__name__)
|
|
363
|
+
return MISSING
|
|
364
|
+
try:
|
|
365
|
+
line = append_record(args.file, verdict)
|
|
366
|
+
except OSError as exc:
|
|
367
|
+
return _io_error("write", args.file, exc)
|
|
368
|
+
entry = json.loads(line)
|
|
369
|
+
sys.stderr.write("gate-log: recorded %s to %s\n"
|
|
370
|
+
% (entry["category"], args.file))
|
|
371
|
+
return PASSED
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _cmd_report(args):
|
|
375
|
+
if not os.path.exists(args.file):
|
|
376
|
+
sys.stderr.write(
|
|
377
|
+
"gate-log: no log at %s -- UNKNOWN, not a clean history. A gate "
|
|
378
|
+
"that was never recorded is not a gate that never blocked.\n"
|
|
379
|
+
% args.file)
|
|
380
|
+
return MISSING
|
|
381
|
+
try:
|
|
382
|
+
entries, corrupt = read_log(args.file)
|
|
383
|
+
except OSError as exc:
|
|
384
|
+
return _io_error("read", args.file, exc)
|
|
385
|
+
if not entries and not corrupt:
|
|
386
|
+
sys.stderr.write(
|
|
387
|
+
"gate-log: %s holds no records -- UNKNOWN. An empty log must "
|
|
388
|
+
"never read as 'never blocked'.\n" % args.file)
|
|
389
|
+
return NOTHING
|
|
390
|
+
summary = summarize(entries, corrupt)
|
|
391
|
+
if args.as_json:
|
|
392
|
+
print(json.dumps(summary, indent=2, sort_keys=True))
|
|
393
|
+
else:
|
|
394
|
+
print(render(summary))
|
|
395
|
+
return exit_code(summary)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def main(argv=None):
|
|
399
|
+
ap = _Parser(description="Append ci-gate verdicts to a durable log and "
|
|
400
|
+
"report the pattern.")
|
|
401
|
+
subs = ap.add_subparsers(dest="command")
|
|
402
|
+
for name, help_text in (("record", "append a ci-gate --json verdict read "
|
|
403
|
+
"from stdin"),
|
|
404
|
+
("report", "summarise the recorded verdicts")):
|
|
405
|
+
sub = subs.add_parser(name, help=help_text)
|
|
406
|
+
sub.add_argument("--file", default=DEFAULT_LOG,
|
|
407
|
+
help="JSONL log path (default: %s)" % DEFAULT_LOG)
|
|
408
|
+
if name == "report":
|
|
409
|
+
sub.add_argument("--json", action="store_true", dest="as_json",
|
|
410
|
+
help="emit the summary as JSON")
|
|
411
|
+
args = ap.parse_args(argv)
|
|
412
|
+
if args.command == "record":
|
|
413
|
+
return _cmd_record(args)
|
|
414
|
+
if args.command == "report":
|
|
415
|
+
return _cmd_report(args)
|
|
416
|
+
# No subcommand. Not a verdict about anything, so it is a usage error --
|
|
417
|
+
# exiting 0 here would let `gate-log.py` alone read as a clean gate.
|
|
418
|
+
ap.error("a subcommand is required: record or report")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
if __name__ == "__main__":
|
|
422
|
+
sys.exit(main())
|