loki-mode 10.1.0 → 10.1.1
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/autonomy/lib/modernize/py_capture.py +642 -0
- package/autonomy/lib/modernize/py_imports.py +260 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/BOARD.md +16 -14
- package/docs/v10/PROGRESS.md +6 -0
- 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/web-app/dist/assets/{AdminPage-2Vvbl-44.js → AdminPage-wMjhCb2R.js} +1 -1
- package/web-app/dist/assets/{Avatar-BOhL0n6E.js → Avatar-DZrKgEX4.js} +1 -1
- package/web-app/dist/assets/{Badge-BBUiD0l_.js → Badge-B98xzprY.js} +1 -1
- package/web-app/dist/assets/{Button-CyWOw1Bk.js → Button-Wo3WJ5Kd.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-n_2b26I6.js → CockpitPage-SU0qpwFJ.js} +1 -1
- package/web-app/dist/assets/{ComparePage-DiVUELHq.js → ComparePage-BEKIJ4I5.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-D3C-yCJk.js → ErrorBoundary-U_89NZcK.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-BMvYMVPw.js → EvidenceReceiptPanel-DKeK7rwF.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-z6xHKZmn.js → GitHubIssuesPanel-Bzwgxvzl.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CukSUgaq.js → GitHubPRsPanel-CLyXZXt9.js} +1 -1
- package/web-app/dist/assets/{HomePage-a677lI6k.js → HomePage-jPDT7src.js} +2 -2
- package/web-app/dist/assets/{LoginPage-2S4fAPpI.js → LoginPage-W91noLcb.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CMxPqcqC.js → MagicPage-DdIXXbgO.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-SS7Oj6Pk.js → MetricsPage-FhjqTGMb.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-BZXdsLzP.js → NotFoundPage-B7gXwg03.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-CqpoFnd2.js → ProjectPage-DMP7-lF9.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-BUth71nx.js → ProjectsPage-BEv3tKH5.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-BdmBARGN.js → SettingsPage-BahxJ6e-.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-BuPyRdsp.js → ShowcasePage-B8ZK_eFm.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-nWi3Y49V.js → SystemSettingsPage-i_ua6JYr.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-Bbzd2c18.js → TeamsPage-BxKtdgg8.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CFaCEASB.js → TemplatesPage-B74RvPRL.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CwMRjG7N.js → TerminalOutput-CusdIPW5.js} +1 -1
- package/web-app/dist/assets/{activity-DSavuwyT.js → activity-BzYTwsTy.js} +1 -1
- package/web-app/dist/assets/{bell-BlhMsQEo.js → bell-DH9nf5zB.js} +1 -1
- package/web-app/dist/assets/{bot-CNA95IKJ.js → bot-HoIZK7eB.js} +1 -1
- package/web-app/dist/assets/{check-DOrNSoDM.js → check-Bg2o382k.js} +1 -1
- package/web-app/dist/assets/{chevron-left-B2USiPOZ.js → chevron-left-v-IUHzhm.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Bo5f9KMG.js → circle-alert-G3MHxw9O.js} +1 -1
- package/web-app/dist/assets/{clock-rzxwnuLM.js → clock-CR8oME3b.js} +1 -1
- package/web-app/dist/assets/{cloud-DjFY4qb6.js → cloud-FNlrpEVb.js} +1 -1
- package/web-app/dist/assets/{code-xml-DoEdsIKW.js → code-xml-THwmjPSI.js} +1 -1
- package/web-app/dist/assets/{database-G9bBiLg8.js → database-DGS4lCam.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-bvddxRmc.js → dollar-sign-D1D7210y.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-B4pgv1xC.js → file-code-corner-BEZS0NVh.js} +1 -1
- package/web-app/dist/assets/{file-plus-Mr4Q0b_Z.js → file-plus-slraMnC-.js} +1 -1
- package/web-app/dist/assets/{globe-BM6uvx4Q.js → globe-Ckj7P6MF.js} +1 -1
- package/web-app/dist/assets/{hammer-Dbg_jerN.js → hammer-BzjDp6gZ.js} +1 -1
- package/web-app/dist/assets/{index-JLmXzxqi.js → index-Dtrn_88z.js} +2 -2
- package/web-app/dist/assets/{layers-duNSn4MK.js → layers-mUcW7Lcv.js} +1 -1
- package/web-app/dist/assets/{loader-circle-BTy_2p55.js → loader-circle-CwrHVSiy.js} +1 -1
- package/web-app/dist/assets/{lock-Da91KLZZ.js → lock-a4yooVvD.js} +1 -1
- package/web-app/dist/assets/{package-CUrUe6vT.js → package-H3syThe6.js} +1 -1
- package/web-app/dist/assets/{plus-CyFRqzGH.js → plus-R5BxuvRb.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-YelQ45nx.js → refresh-cw-CA64KPjz.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-BIU7xZyT.js → rotate-ccw-DbYX6fj6.js} +1 -1
- package/web-app/dist/assets/{scroll-text-C88oxteF.js → scroll-text-DSAnxmlo.js} +1 -1
- package/web-app/dist/assets/{server-X3H7bWFy.js → server--M5B5u1M.js} +1 -1
- package/web-app/dist/assets/{shield-alert-gpP8kh02.js → shield-alert-DL9xNxeK.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-BxwJ7kT_.js → thumbs-up-2MLPuEWk.js} +1 -1
- package/web-app/dist/assets/{trash-2-M7R3o6Ds.js → trash-2-CjFzSYHy.js} +1 -1
- package/web-app/dist/assets/{trending-down-DYXErqkJ.js → trending-down-BVWPFfUX.js} +1 -1
- package/web-app/dist/assets/{trending-up-CqZThz26.js → trending-up-CwcHClXI.js} +1 -1
- package/web-app/dist/assets/{upload-BZPVsx2j.js → upload-ODPqesIu.js} +1 -1
- package/web-app/dist/assets/{usePolling-CFDpdEmJ.js → usePolling-5s1fbj3D.js} +1 -1
- package/web-app/dist/assets/{user-CG96I_PT.js → user-B249yxJV.js} +1 -1
- package/web-app/dist/index.html +1 -1
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v10.1.
|
|
6
|
+
# Loki Mode v10.1.1
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
470
470
|
|
|
471
471
|
---
|
|
472
472
|
|
|
473
|
-
**v10.1.
|
|
473
|
+
**v10.1.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
10.1.
|
|
1
|
+
10.1.1
|
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""autonomy/lib/modernize/py_capture.py
|
|
3
|
+
|
|
4
|
+
Loki Modernize (M-09): Python 2/3 capture tracer.
|
|
5
|
+
|
|
6
|
+
Wraps a unit's public entry functions, calls each with a declared input, and
|
|
7
|
+
writes one type-tagged, canonical-JSON "golden record" per case: arguments,
|
|
8
|
+
return value or exception, stdout, and any files the call wrote under a
|
|
9
|
+
fresh temp root. The source below runs unmodified on CPython 2.7 and 3.x --
|
|
10
|
+
picking which interpreter runs it (LOKI_MOD_OLD_RUNTIME or PATH discovery)
|
|
11
|
+
is the caller's job, not this script's (see docs/v10/MODERNIZE.md section
|
|
12
|
+
3.2). Standalone: this file has no dependency on M-01's TypeScript types.
|
|
13
|
+
M-12 (oracle seal) and M-10 (coverage-guided search) consume the two record
|
|
14
|
+
formats documented below.
|
|
15
|
+
|
|
16
|
+
INPUT (--cases, one JSON object per line):
|
|
17
|
+
{"entry": "<function name>", "args": [<tagged>, ...],
|
|
18
|
+
"kwargs": {"<name>": <tagged>, ...}}
|
|
19
|
+
|
|
20
|
+
OUTPUT (--out, one JSON object per line, same order as input):
|
|
21
|
+
{
|
|
22
|
+
"format": 1,
|
|
23
|
+
"case": "<sha256 hex of the canonical entry+args+kwargs>",
|
|
24
|
+
"entry": "<function name>",
|
|
25
|
+
"args": [<tagged>, ...],
|
|
26
|
+
"kwargs": {"<name>": <tagged>, ...},
|
|
27
|
+
"return": <tagged> | null, -- exactly one of return/exc is set
|
|
28
|
+
"exc": {"t": "exc", "type": "<ClassName>", "args": [<tagged>, ...]} | null,
|
|
29
|
+
"stdout": <tagged text>,
|
|
30
|
+
"files": [{"path": "<relative posix path>", "content": <tagged>}, ...],
|
|
31
|
+
"not_proven": ["<reason>", ...]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
TAGGED VALUE (canonical JSON: sort_keys, no spaces, ensure_ascii, no NaN/Inf
|
|
35
|
+
tokens -- this crosses into a TypeScript consumer, so every field must be
|
|
36
|
+
valid strict JSON):
|
|
37
|
+
{"t": "none"}
|
|
38
|
+
{"t": "bool", "v": true|false}
|
|
39
|
+
{"t": "int", "v": "<decimal string>"} -- string: JS loses precision above 2**53
|
|
40
|
+
{"t": "float", "v": <number>|"nan"|"inf"|"-inf"}
|
|
41
|
+
{"t": "bytes", "v": "<base64>"} -- py2 str / py3 bytes
|
|
42
|
+
{"t": "text", "v": "<str>"} -- py2 unicode / py3 str
|
|
43
|
+
{"t": "list", "v": [<tagged>, ...]}
|
|
44
|
+
{"t": "tuple", "v": [<tagged>, ...]}
|
|
45
|
+
{"t": "dict", "v": [[<tagged key>, <tagged value>], ...]} -- pairs sorted
|
|
46
|
+
by the canonical JSON encoding of the key, so non-string keys (int,
|
|
47
|
+
bytes, tuple) still sort deterministically without a TypeError.
|
|
48
|
+
{"t": "set", "v": [<tagged>, ...]} -- elements sorted by the
|
|
49
|
+
canonical JSON encoding of each tagged element, same rule as above
|
|
50
|
+
{"t": "frozenset", "v": [<tagged>, ...]} -- same sort rule as set
|
|
51
|
+
{"t": "decimal", "v": "<str(Decimal)>"}
|
|
52
|
+
{"t": "datetime", "v": "<isoformat>"} -- datetime.datetime
|
|
53
|
+
{"t": "date", "v": "<isoformat>"} -- datetime.date
|
|
54
|
+
{"t": "time", "v": "<isoformat>"} -- datetime.time
|
|
55
|
+
{"t": "bytearray", "v": "<hex>"}
|
|
56
|
+
{"t": "unsupported", "type": "<type name>"} -- anything else (a custom
|
|
57
|
+
class instance, for example). Never repr(): an object's repr can
|
|
58
|
+
embed a memory address, which is not deterministic across runs and
|
|
59
|
+
would poison the golden record. Every tag above is gated on the
|
|
60
|
+
EXACT type of the value, never isinstance(): a subclass of a
|
|
61
|
+
supported type (a namedtuple, an OrderedDict, an IntEnum member, a
|
|
62
|
+
plain int subclass) is tagged unsupported under its own type name,
|
|
63
|
+
not silently coerced to the base type's tag. Capture is never
|
|
64
|
+
skipped for these either, but every occurrence -- at any nesting
|
|
65
|
+
depth inside the return value or a raised exception's args -- adds
|
|
66
|
+
"unsupported:<type name> at <call site>" to "not_proven" (see
|
|
67
|
+
below). This is what keeps the honest-verdict rule honest: two
|
|
68
|
+
unsupported values of the same type, wherever they show up, must
|
|
69
|
+
never be read as proven equal, only as not proven.
|
|
70
|
+
|
|
71
|
+
Boundary declarations (--boundaries FILE, optional JSON object):
|
|
72
|
+
{"<arg name>": "text"|"bytes", "return": "text"|"bytes"}
|
|
73
|
+
A text/bytes-valued argument or return with no matching declaration is still
|
|
74
|
+
captured -- capture is never skipped -- but the record's "not_proven" list
|
|
75
|
+
gets "boundary:<name> undeclared", per the honest-verdict rule (section 7).
|
|
76
|
+
The same rule covers unsupported types: see the "unsupported" tag above.
|
|
77
|
+
The check recurses into lists/tuples/dicts, so a text or bytes value nested
|
|
78
|
+
inside a returned structure is checked too, not just top-level scalars. A
|
|
79
|
+
structure that mixes both kinds cannot be proven by one declared type and is
|
|
80
|
+
always flagged, declared or not: "boundary:<name> mixes text and bytes,
|
|
81
|
+
cannot be declared by one type".
|
|
82
|
+
|
|
83
|
+
COVERAGE (--coverage, optional, one JSON object, written once for the whole
|
|
84
|
+
run across every case):
|
|
85
|
+
{
|
|
86
|
+
"unit": "<path as given>",
|
|
87
|
+
"entries": ["<fn>", ...],
|
|
88
|
+
"cases": <int>,
|
|
89
|
+
"branches_total": <int>,
|
|
90
|
+
"branches_taken": <int>,
|
|
91
|
+
"branch_pct": <float>,
|
|
92
|
+
"missing": [{"line": <int>, "outcome": "true"|"false"}, ...]
|
|
93
|
+
}
|
|
94
|
+
Branch coverage is arc-based (decision line -> target line, sys.settrace),
|
|
95
|
+
scoped to lines inside the unit file only. Known ceilings (ponytail): a
|
|
96
|
+
one-line `if x: return y` and comprehension/ternary ifs are invisible to
|
|
97
|
+
this AST walk; an `if` as the very last statement in a block with no `else`
|
|
98
|
+
has no discoverable false-target line, so it is not counted; and a
|
|
99
|
+
try/except/finally is walked for the If/For/While branches nested inside it
|
|
100
|
+
but is not itself a counted branch point (which handler fired, or whether
|
|
101
|
+
none did, is not scored). Upgrade path if a real repo needs it: swap the AST
|
|
102
|
+
walk for coverage.py's arc analysis once that dependency is worth taking on.
|
|
103
|
+
"""
|
|
104
|
+
from __future__ import print_function
|
|
105
|
+
|
|
106
|
+
import argparse
|
|
107
|
+
import ast
|
|
108
|
+
import base64
|
|
109
|
+
import binascii
|
|
110
|
+
import datetime
|
|
111
|
+
import hashlib
|
|
112
|
+
import json
|
|
113
|
+
import os
|
|
114
|
+
import runpy
|
|
115
|
+
import shutil
|
|
116
|
+
import sys
|
|
117
|
+
import tempfile
|
|
118
|
+
from decimal import Decimal
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
from StringIO import StringIO # py2
|
|
122
|
+
except ImportError:
|
|
123
|
+
from io import StringIO # py3
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
from inspect import signature as _signature # py3
|
|
127
|
+
except ImportError:
|
|
128
|
+
_signature = None # py2: fall back to getargspec below
|
|
129
|
+
|
|
130
|
+
PY2 = sys.version_info[0] == 2
|
|
131
|
+
|
|
132
|
+
if PY2:
|
|
133
|
+
TEXT_TYPES = (unicode,) # noqa: F821
|
|
134
|
+
BYTES_TYPES = (str,)
|
|
135
|
+
INT_TYPES = (int, long) # noqa: F821
|
|
136
|
+
else:
|
|
137
|
+
TEXT_TYPES = (str,)
|
|
138
|
+
BYTES_TYPES = (bytes,)
|
|
139
|
+
INT_TYPES = (int,)
|
|
140
|
+
|
|
141
|
+
_active_unit_path = None
|
|
142
|
+
_arcs = set()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
# Type-tagged canonical JSON
|
|
147
|
+
# --------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _b64encode(data):
|
|
150
|
+
out = base64.b64encode(data)
|
|
151
|
+
if not isinstance(out, str):
|
|
152
|
+
out = out.decode("ascii")
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _b64decode(data):
|
|
157
|
+
return base64.b64decode(data)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _hexencode(data):
|
|
161
|
+
out = binascii.hexlify(bytes(data))
|
|
162
|
+
if not isinstance(out, str):
|
|
163
|
+
out = out.decode("ascii")
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _hexdecode(data):
|
|
168
|
+
return bytearray(binascii.unhexlify(data))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ponytail: naive (tz-less) isoformat only -- datetime.datetime.fromisoformat
|
|
172
|
+
# does not exist on 2.7 or early 3.x, so this parses the common case by hand
|
|
173
|
+
# instead of adding a dependency. tag() still captures a tz-aware value's
|
|
174
|
+
# offset faithfully in the isoformat string; untag() just refuses to guess
|
|
175
|
+
# at it. Upgrade path if a real repo needs tz-aware round-trip: parse the
|
|
176
|
+
# trailing 'Z'/'+HH:MM' suffix into a tzinfo.
|
|
177
|
+
def _parse_iso_date(s):
|
|
178
|
+
y, m, d = s.split("-")
|
|
179
|
+
return datetime.date(int(y), int(m), int(d))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_iso_time(s):
|
|
183
|
+
if s[-1:] == "Z" or "+" in s[1:] or "-" in s[1:]:
|
|
184
|
+
raise ValueError("timezone-aware time %r is not supported by untag()" % (s,))
|
|
185
|
+
if "." in s:
|
|
186
|
+
hms, frac = s.split(".", 1)
|
|
187
|
+
micro = int((frac + "000000")[:6])
|
|
188
|
+
else:
|
|
189
|
+
hms, micro = s, 0
|
|
190
|
+
h, mi, se = hms.split(":")
|
|
191
|
+
return datetime.time(int(h), int(mi), int(se), micro)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _parse_iso_datetime(s):
|
|
195
|
+
sep = "T" if "T" in s else " "
|
|
196
|
+
date_part, time_part = s.split(sep, 1)
|
|
197
|
+
d = _parse_iso_date(date_part)
|
|
198
|
+
t = _parse_iso_time(time_part)
|
|
199
|
+
return datetime.datetime(d.year, d.month, d.day, t.hour, t.minute, t.second, t.microsecond)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _tag_float(value):
|
|
203
|
+
if value != value: # nan
|
|
204
|
+
return {"t": "float", "v": "nan"}
|
|
205
|
+
if value == float("inf"):
|
|
206
|
+
return {"t": "float", "v": "inf"}
|
|
207
|
+
if value == float("-inf"):
|
|
208
|
+
return {"t": "float", "v": "-inf"}
|
|
209
|
+
return {"t": "float", "v": value}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def canon(obj):
|
|
213
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
|
|
214
|
+
ensure_ascii=True, allow_nan=False)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def tag(value):
|
|
218
|
+
# Gated on the EXACT type (type(value) is X / in {...}), never isinstance:
|
|
219
|
+
# a subclass of a supported type (namedtuple vs tuple, OrderedDict vs
|
|
220
|
+
# dict, IntEnum vs int, a plain int subclass, ...) must fall through to
|
|
221
|
+
# the "unsupported" catch-all below, naming its own type. isinstance()
|
|
222
|
+
# would silently tag it as its base type, so two values that are NOT the
|
|
223
|
+
# same type -- Color.RED vs plain 1, a namedtuple vs a plain tuple --
|
|
224
|
+
# would read as proven equal. bool/long/unicode/str (py2) are kept as
|
|
225
|
+
# named exact types, not collapsed by isinstance either.
|
|
226
|
+
vtype = type(value)
|
|
227
|
+
if value is None:
|
|
228
|
+
return {"t": "none"}
|
|
229
|
+
if vtype is bool:
|
|
230
|
+
return {"t": "bool", "v": value}
|
|
231
|
+
if vtype in INT_TYPES:
|
|
232
|
+
return {"t": "int", "v": str(int(value))}
|
|
233
|
+
if vtype is float:
|
|
234
|
+
return _tag_float(value)
|
|
235
|
+
if vtype is Decimal:
|
|
236
|
+
return {"t": "decimal", "v": str(value)}
|
|
237
|
+
if vtype is bytearray:
|
|
238
|
+
return {"t": "bytearray", "v": _hexencode(value)}
|
|
239
|
+
if vtype in BYTES_TYPES:
|
|
240
|
+
return {"t": "bytes", "v": _b64encode(value)}
|
|
241
|
+
if vtype in TEXT_TYPES:
|
|
242
|
+
return {"t": "text", "v": value}
|
|
243
|
+
if vtype is datetime.datetime:
|
|
244
|
+
return {"t": "datetime", "v": value.isoformat()}
|
|
245
|
+
if vtype is datetime.date:
|
|
246
|
+
return {"t": "date", "v": value.isoformat()}
|
|
247
|
+
if vtype is datetime.time:
|
|
248
|
+
return {"t": "time", "v": value.isoformat()}
|
|
249
|
+
if vtype is tuple:
|
|
250
|
+
return {"t": "tuple", "v": [tag(x) for x in value]}
|
|
251
|
+
if vtype is frozenset:
|
|
252
|
+
return {"t": "frozenset", "v": sorted((tag(x) for x in value), key=canon)}
|
|
253
|
+
if vtype is set:
|
|
254
|
+
return {"t": "set", "v": sorted((tag(x) for x in value), key=canon)}
|
|
255
|
+
if vtype is list:
|
|
256
|
+
return {"t": "list", "v": [tag(x) for x in value]}
|
|
257
|
+
if vtype is dict:
|
|
258
|
+
pairs = [[tag(k), tag(v)] for k, v in value.items()]
|
|
259
|
+
pairs.sort(key=lambda kv: canon(kv[0]))
|
|
260
|
+
return {"t": "dict", "v": pairs}
|
|
261
|
+
return {"t": "unsupported", "type": type(value).__name__}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _text_tag(value):
|
|
265
|
+
if PY2 and isinstance(value, str):
|
|
266
|
+
try:
|
|
267
|
+
value = value.decode("utf-8")
|
|
268
|
+
except UnicodeDecodeError:
|
|
269
|
+
value = value.decode("latin-1")
|
|
270
|
+
return {"t": "text", "v": value}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def untag(rec):
|
|
274
|
+
t = rec.get("t")
|
|
275
|
+
if t == "none":
|
|
276
|
+
return None
|
|
277
|
+
if t == "bool":
|
|
278
|
+
return bool(rec["v"])
|
|
279
|
+
if t == "int":
|
|
280
|
+
return int(rec["v"])
|
|
281
|
+
if t == "float":
|
|
282
|
+
v = rec["v"]
|
|
283
|
+
if v == "nan":
|
|
284
|
+
return float("nan")
|
|
285
|
+
if v == "inf":
|
|
286
|
+
return float("inf")
|
|
287
|
+
if v == "-inf":
|
|
288
|
+
return float("-inf")
|
|
289
|
+
return float(v)
|
|
290
|
+
if t == "bytes":
|
|
291
|
+
return _b64decode(rec["v"])
|
|
292
|
+
if t == "text":
|
|
293
|
+
return rec["v"] if not PY2 else unicode(rec["v"]) # noqa: F821
|
|
294
|
+
if t == "decimal":
|
|
295
|
+
return Decimal(rec["v"])
|
|
296
|
+
if t == "bytearray":
|
|
297
|
+
return _hexdecode(rec["v"])
|
|
298
|
+
if t == "datetime":
|
|
299
|
+
return _parse_iso_datetime(rec["v"])
|
|
300
|
+
if t == "date":
|
|
301
|
+
return _parse_iso_date(rec["v"])
|
|
302
|
+
if t == "time":
|
|
303
|
+
return _parse_iso_time(rec["v"])
|
|
304
|
+
if t == "list":
|
|
305
|
+
return [untag(x) for x in rec["v"]]
|
|
306
|
+
if t == "tuple":
|
|
307
|
+
return tuple(untag(x) for x in rec["v"])
|
|
308
|
+
if t == "set":
|
|
309
|
+
return set(untag(x) for x in rec["v"])
|
|
310
|
+
if t == "frozenset":
|
|
311
|
+
return frozenset(untag(x) for x in rec["v"])
|
|
312
|
+
if t == "dict":
|
|
313
|
+
return dict((untag(k), untag(v)) for k, v in rec["v"])
|
|
314
|
+
if t == "unsupported":
|
|
315
|
+
raise ValueError("cannot restore an 'unsupported' tagged value (type %s)" % rec.get("type"))
|
|
316
|
+
raise ValueError("unknown tag: %r" % (t,))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def case_id(entry, args_tagged, kwargs_tagged):
|
|
320
|
+
payload = canon({"entry": entry, "args": args_tagged, "kwargs": kwargs_tagged})
|
|
321
|
+
if not PY2:
|
|
322
|
+
payload = payload.encode("utf-8")
|
|
323
|
+
return hashlib.sha256(payload).hexdigest()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --------------------------------------------------------------------------
|
|
327
|
+
# Branch coverage: AST branch points + sys.settrace arcs
|
|
328
|
+
# --------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
# py2.7's ast has no single Try node: a plain try/except is TryExcept, a
|
|
331
|
+
# plain try/finally is TryFinally, and try/except/finally is a TryFinally
|
|
332
|
+
# wrapping a TryExcept. py3 has one ast.Try with orelse/finalbody built in.
|
|
333
|
+
# Building this tuple from whichever names actually exist on this
|
|
334
|
+
# interpreter's `ast` module keeps branch_points() working on both.
|
|
335
|
+
_TRY_TYPES = tuple(
|
|
336
|
+
getattr(ast, name) for name in ("Try", "TryExcept", "TryFinally") if hasattr(ast, name)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def branch_points(tree):
|
|
341
|
+
"""(decision_line, true_target_line, false_target_line) per If/For/While."""
|
|
342
|
+
points = []
|
|
343
|
+
async_def = getattr(ast, "AsyncFunctionDef", ast.FunctionDef)
|
|
344
|
+
|
|
345
|
+
def block(stmts, after):
|
|
346
|
+
for i, stmt in enumerate(stmts):
|
|
347
|
+
nxt = stmts[i + 1].lineno if i + 1 < len(stmts) else after
|
|
348
|
+
if isinstance(stmt, ast.If):
|
|
349
|
+
true_line = stmt.body[0].lineno
|
|
350
|
+
false_line = stmt.orelse[0].lineno if stmt.orelse else nxt
|
|
351
|
+
if false_line is not None:
|
|
352
|
+
points.append((stmt.lineno, true_line, false_line))
|
|
353
|
+
block(stmt.body, nxt)
|
|
354
|
+
if stmt.orelse:
|
|
355
|
+
block(stmt.orelse, nxt)
|
|
356
|
+
elif isinstance(stmt, (ast.For, ast.While)):
|
|
357
|
+
enter_line = stmt.body[0].lineno
|
|
358
|
+
skip_line = stmt.orelse[0].lineno if stmt.orelse else nxt
|
|
359
|
+
if skip_line is not None:
|
|
360
|
+
points.append((stmt.lineno, enter_line, skip_line))
|
|
361
|
+
block(stmt.body, stmt.lineno)
|
|
362
|
+
if stmt.orelse:
|
|
363
|
+
block(stmt.orelse, nxt)
|
|
364
|
+
elif isinstance(stmt, _TRY_TYPES):
|
|
365
|
+
block(getattr(stmt, "body", []), nxt)
|
|
366
|
+
for handler in getattr(stmt, "handlers", []):
|
|
367
|
+
block(handler.body, nxt)
|
|
368
|
+
if getattr(stmt, "orelse", None):
|
|
369
|
+
block(stmt.orelse, nxt)
|
|
370
|
+
if getattr(stmt, "finalbody", None):
|
|
371
|
+
block(stmt.finalbody, nxt)
|
|
372
|
+
elif isinstance(stmt, (ast.FunctionDef, async_def, ast.ClassDef)):
|
|
373
|
+
block(stmt.body, None)
|
|
374
|
+
elif isinstance(getattr(stmt, "body", None), list):
|
|
375
|
+
block(stmt.body, nxt)
|
|
376
|
+
|
|
377
|
+
block(tree.body, None)
|
|
378
|
+
return points
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _make_line_tracer():
|
|
382
|
+
state = {"prev": None}
|
|
383
|
+
|
|
384
|
+
def tracer(frame, event, arg): # noqa: ARG001
|
|
385
|
+
if event == "line":
|
|
386
|
+
cur = frame.f_lineno
|
|
387
|
+
if state["prev"] is not None:
|
|
388
|
+
_arcs.add((state["prev"], cur))
|
|
389
|
+
state["prev"] = cur
|
|
390
|
+
return tracer
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
return tracer
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _trace_calls(frame, event, arg): # noqa: ARG001
|
|
397
|
+
if event != "call":
|
|
398
|
+
return None
|
|
399
|
+
if os.path.abspath(frame.f_code.co_filename) != _active_unit_path:
|
|
400
|
+
return None
|
|
401
|
+
return _make_line_tracer()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# --------------------------------------------------------------------------
|
|
405
|
+
# Case execution
|
|
406
|
+
# --------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def _bound_names(func, args, kwargs):
|
|
409
|
+
if _signature is not None:
|
|
410
|
+
try:
|
|
411
|
+
bound = _signature(func).bind_partial(*args, **kwargs)
|
|
412
|
+
return list(bound.arguments.keys())
|
|
413
|
+
except TypeError:
|
|
414
|
+
return []
|
|
415
|
+
import inspect
|
|
416
|
+
try:
|
|
417
|
+
spec = inspect.getargspec(func) # py2 only path
|
|
418
|
+
except TypeError:
|
|
419
|
+
return []
|
|
420
|
+
return list(spec.args[:len(args)]) + list(kwargs.keys())
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _text_bytes_kinds(value, kinds):
|
|
424
|
+
"""Recurse into lists/tuples/dicts so a text/bytes value nested inside a
|
|
425
|
+
returned structure (a dict value, a list element) is still seen -- a
|
|
426
|
+
scalar-only check would silently let an ambiguous nested field through
|
|
427
|
+
undeclared, which is exactly the trap section 7's honest-verdict rule
|
|
428
|
+
exists to catch."""
|
|
429
|
+
if isinstance(value, TEXT_TYPES):
|
|
430
|
+
kinds.add("text")
|
|
431
|
+
elif isinstance(value, BYTES_TYPES):
|
|
432
|
+
kinds.add("bytes")
|
|
433
|
+
elif isinstance(value, (list, tuple)):
|
|
434
|
+
for item in value:
|
|
435
|
+
_text_bytes_kinds(item, kinds)
|
|
436
|
+
elif isinstance(value, dict):
|
|
437
|
+
for k, v in value.items():
|
|
438
|
+
_text_bytes_kinds(k, kinds)
|
|
439
|
+
_text_bytes_kinds(v, kinds)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _check_boundary(name, value, declared):
|
|
443
|
+
kinds = set()
|
|
444
|
+
_text_bytes_kinds(value, kinds)
|
|
445
|
+
if not kinds:
|
|
446
|
+
return None
|
|
447
|
+
if len(kinds) > 1:
|
|
448
|
+
return "boundary:%s mixes text and bytes, cannot be declared by one type" % name
|
|
449
|
+
actual = next(iter(kinds))
|
|
450
|
+
if name not in declared:
|
|
451
|
+
return "boundary:%s undeclared" % name
|
|
452
|
+
if declared[name] != actual:
|
|
453
|
+
return "boundary:%s declared %s but runtime type is %s" % (name, declared[name], actual)
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _unsupported_not_proven(tagged, path):
|
|
458
|
+
"""Recurse into an already-tagged structure (a return value or an
|
|
459
|
+
exception arg) and turn every 'unsupported' tag at any nesting depth
|
|
460
|
+
into a not_proven reason naming the type and where it was found. This is
|
|
461
|
+
what stops an unproven value from ever being read as proven equal: it
|
|
462
|
+
is never bare-equality-compared, it is flagged not_proven instead."""
|
|
463
|
+
out = []
|
|
464
|
+
t = tagged.get("t")
|
|
465
|
+
if t == "unsupported":
|
|
466
|
+
out.append("unsupported:%s at %s" % (tagged.get("type"), path))
|
|
467
|
+
elif t in ("list", "tuple", "set", "frozenset"):
|
|
468
|
+
for i, item in enumerate(tagged["v"]):
|
|
469
|
+
out.extend(_unsupported_not_proven(item, "%s[%d]" % (path, i)))
|
|
470
|
+
elif t == "dict":
|
|
471
|
+
for i, (k, v) in enumerate(tagged["v"]):
|
|
472
|
+
out.extend(_unsupported_not_proven(k, "%s.key[%d]" % (path, i)))
|
|
473
|
+
out.extend(_unsupported_not_proven(v, "%s.value[%d]" % (path, i)))
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def run_case(func, args, kwargs):
|
|
478
|
+
workdir = tempfile.mkdtemp(prefix="py_capture_case_")
|
|
479
|
+
prev_cwd = os.getcwd()
|
|
480
|
+
prev_stdout = sys.stdout
|
|
481
|
+
stdout_buf = StringIO()
|
|
482
|
+
outcome = {"had_result": False, "result": None, "result_tag": None, "exc_tag": None}
|
|
483
|
+
try:
|
|
484
|
+
os.chdir(workdir)
|
|
485
|
+
sys.stdout = stdout_buf
|
|
486
|
+
sys.settrace(_trace_calls)
|
|
487
|
+
try:
|
|
488
|
+
result = func(*args, **kwargs)
|
|
489
|
+
outcome["had_result"] = True
|
|
490
|
+
outcome["result"] = result
|
|
491
|
+
outcome["result_tag"] = tag(result)
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
outcome["exc_tag"] = {
|
|
494
|
+
"t": "exc",
|
|
495
|
+
"type": type(exc).__name__,
|
|
496
|
+
"args": [tag(a) for a in getattr(exc, "args", ())],
|
|
497
|
+
}
|
|
498
|
+
finally:
|
|
499
|
+
sys.settrace(None)
|
|
500
|
+
finally:
|
|
501
|
+
sys.stdout = prev_stdout
|
|
502
|
+
os.chdir(prev_cwd)
|
|
503
|
+
files = []
|
|
504
|
+
for root, _dirs, filenames in os.walk(workdir):
|
|
505
|
+
for name in filenames:
|
|
506
|
+
full = os.path.join(root, name)
|
|
507
|
+
rel = os.path.relpath(full, workdir).replace(os.sep, "/")
|
|
508
|
+
with open(full, "rb") as fh:
|
|
509
|
+
content = fh.read()
|
|
510
|
+
files.append({"path": rel, "content": tag(content)})
|
|
511
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
512
|
+
files.sort(key=lambda f: f["path"])
|
|
513
|
+
outcome["stdout_tag"] = _text_tag(stdout_buf.getvalue())
|
|
514
|
+
outcome["files"] = files
|
|
515
|
+
return outcome
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# --------------------------------------------------------------------------
|
|
519
|
+
# CLI
|
|
520
|
+
# --------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
def main(argv=None):
|
|
523
|
+
parser = argparse.ArgumentParser(description="Python 2/3 capture tracer (Loki Modernize M-09)")
|
|
524
|
+
parser.add_argument("--unit", required=True, help="path to the unit's .py source")
|
|
525
|
+
parser.add_argument("--cases", required=True, help="input cases, one JSON object per line")
|
|
526
|
+
parser.add_argument("--out", required=True, help="output cases.jsonl path")
|
|
527
|
+
parser.add_argument("--coverage", help="output coverage.json path")
|
|
528
|
+
parser.add_argument("--boundaries", help="JSON file of declared text/bytes boundaries")
|
|
529
|
+
args = parser.parse_args(argv)
|
|
530
|
+
|
|
531
|
+
unit_path = os.path.abspath(args.unit)
|
|
532
|
+
if not os.path.isfile(unit_path):
|
|
533
|
+
sys.stderr.write("py_capture: unit not found: %s\n" % args.unit)
|
|
534
|
+
return 2
|
|
535
|
+
|
|
536
|
+
declared = {}
|
|
537
|
+
if args.boundaries:
|
|
538
|
+
with open(args.boundaries, "r") as fh:
|
|
539
|
+
declared = json.load(fh)
|
|
540
|
+
|
|
541
|
+
global _active_unit_path
|
|
542
|
+
_active_unit_path = unit_path
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
namespace = runpy.run_path(unit_path, run_name="_py_capture_unit")
|
|
546
|
+
except Exception as exc:
|
|
547
|
+
sys.stderr.write("py_capture: failed to load unit %s: %s\n" % (args.unit, exc))
|
|
548
|
+
return 2
|
|
549
|
+
|
|
550
|
+
with open(args.cases, "r") as fh:
|
|
551
|
+
case_lines = [line for line in fh if line.strip()]
|
|
552
|
+
|
|
553
|
+
entries_used = set()
|
|
554
|
+
records = []
|
|
555
|
+
for line in case_lines:
|
|
556
|
+
spec = json.loads(line)
|
|
557
|
+
entry = spec["entry"]
|
|
558
|
+
entries_used.add(entry)
|
|
559
|
+
func = namespace.get(entry)
|
|
560
|
+
if func is None or not callable(func):
|
|
561
|
+
sys.stderr.write("py_capture: entry not found or not callable: %s\n" % entry)
|
|
562
|
+
return 2
|
|
563
|
+
|
|
564
|
+
args_tagged = spec.get("args", [])
|
|
565
|
+
kwargs_tagged = spec.get("kwargs", {})
|
|
566
|
+
call_args = [untag(a) for a in args_tagged]
|
|
567
|
+
call_kwargs = {k: untag(v) for k, v in kwargs_tagged.items()}
|
|
568
|
+
|
|
569
|
+
outcome = run_case(func, call_args, call_kwargs)
|
|
570
|
+
|
|
571
|
+
not_proven = []
|
|
572
|
+
bound_names = _bound_names(func, call_args, call_kwargs)
|
|
573
|
+
for i, name in enumerate(bound_names):
|
|
574
|
+
if i < len(call_args):
|
|
575
|
+
reason = _check_boundary(name, call_args[i], declared)
|
|
576
|
+
elif name in call_kwargs:
|
|
577
|
+
reason = _check_boundary(name, call_kwargs[name], declared)
|
|
578
|
+
else:
|
|
579
|
+
reason = None
|
|
580
|
+
if reason:
|
|
581
|
+
not_proven.append(reason)
|
|
582
|
+
if outcome["had_result"]:
|
|
583
|
+
reason = _check_boundary("return", outcome["result"], declared)
|
|
584
|
+
if reason:
|
|
585
|
+
not_proven.append(reason)
|
|
586
|
+
not_proven.extend(_unsupported_not_proven(outcome["result_tag"], "return"))
|
|
587
|
+
if outcome["exc_tag"] is not None:
|
|
588
|
+
for i, a in enumerate(outcome["exc_tag"]["args"]):
|
|
589
|
+
not_proven.extend(_unsupported_not_proven(a, "exc.args[%d]" % i))
|
|
590
|
+
|
|
591
|
+
records.append({
|
|
592
|
+
"format": 1,
|
|
593
|
+
"case": case_id(entry, args_tagged, kwargs_tagged),
|
|
594
|
+
"entry": entry,
|
|
595
|
+
"args": args_tagged,
|
|
596
|
+
"kwargs": kwargs_tagged,
|
|
597
|
+
"return": outcome["result_tag"] if outcome["had_result"] else None,
|
|
598
|
+
"exc": outcome["exc_tag"],
|
|
599
|
+
"stdout": outcome["stdout_tag"],
|
|
600
|
+
"files": outcome["files"],
|
|
601
|
+
"not_proven": not_proven,
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
with open(args.out, "w") as fh:
|
|
605
|
+
for record in records:
|
|
606
|
+
fh.write(canon(record))
|
|
607
|
+
fh.write("\n")
|
|
608
|
+
|
|
609
|
+
if args.coverage:
|
|
610
|
+
with open(unit_path, "r") as fh:
|
|
611
|
+
tree = ast.parse(fh.read())
|
|
612
|
+
points = branch_points(tree)
|
|
613
|
+
total = 0
|
|
614
|
+
taken = 0
|
|
615
|
+
missing = []
|
|
616
|
+
for decision_line, true_line, false_line in points:
|
|
617
|
+
for outcome_name, target in (("true", true_line), ("false", false_line)):
|
|
618
|
+
total += 1
|
|
619
|
+
if (decision_line, target) in _arcs:
|
|
620
|
+
taken += 1
|
|
621
|
+
else:
|
|
622
|
+
missing.append({"line": decision_line, "outcome": outcome_name})
|
|
623
|
+
missing.sort(key=lambda m: (m["line"], m["outcome"]))
|
|
624
|
+
branch_pct = round(100.0 * taken / total, 4) if total else 100.0
|
|
625
|
+
coverage = {
|
|
626
|
+
"unit": args.unit,
|
|
627
|
+
"entries": sorted(entries_used),
|
|
628
|
+
"cases": len(records),
|
|
629
|
+
"branches_total": total,
|
|
630
|
+
"branches_taken": taken,
|
|
631
|
+
"branch_pct": branch_pct,
|
|
632
|
+
"missing": missing,
|
|
633
|
+
}
|
|
634
|
+
with open(args.coverage, "w") as fh:
|
|
635
|
+
fh.write(canon(coverage))
|
|
636
|
+
fh.write("\n")
|
|
637
|
+
|
|
638
|
+
return 0
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
if __name__ == "__main__":
|
|
642
|
+
sys.exit(main())
|