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.
Files changed (69) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/modernize/py_capture.py +642 -0
  4. package/autonomy/lib/modernize/py_imports.py +260 -0
  5. package/dashboard/__init__.py +1 -1
  6. package/docs/INSTALLATION.md +1 -1
  7. package/docs/v10/BOARD.md +16 -14
  8. package/docs/v10/PROGRESS.md +6 -0
  9. package/loki-ts/dist/loki.js +2 -2
  10. package/mcp/__init__.py +1 -1
  11. package/package.json +1 -1
  12. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  13. package/web-app/dist/assets/{AdminPage-2Vvbl-44.js → AdminPage-wMjhCb2R.js} +1 -1
  14. package/web-app/dist/assets/{Avatar-BOhL0n6E.js → Avatar-DZrKgEX4.js} +1 -1
  15. package/web-app/dist/assets/{Badge-BBUiD0l_.js → Badge-B98xzprY.js} +1 -1
  16. package/web-app/dist/assets/{Button-CyWOw1Bk.js → Button-Wo3WJ5Kd.js} +1 -1
  17. package/web-app/dist/assets/{CockpitPage-n_2b26I6.js → CockpitPage-SU0qpwFJ.js} +1 -1
  18. package/web-app/dist/assets/{ComparePage-DiVUELHq.js → ComparePage-BEKIJ4I5.js} +1 -1
  19. package/web-app/dist/assets/{ErrorBoundary-D3C-yCJk.js → ErrorBoundary-U_89NZcK.js} +1 -1
  20. package/web-app/dist/assets/{EvidenceReceiptPanel-BMvYMVPw.js → EvidenceReceiptPanel-DKeK7rwF.js} +1 -1
  21. package/web-app/dist/assets/{GitHubIssuesPanel-z6xHKZmn.js → GitHubIssuesPanel-Bzwgxvzl.js} +1 -1
  22. package/web-app/dist/assets/{GitHubPRsPanel-CukSUgaq.js → GitHubPRsPanel-CLyXZXt9.js} +1 -1
  23. package/web-app/dist/assets/{HomePage-a677lI6k.js → HomePage-jPDT7src.js} +2 -2
  24. package/web-app/dist/assets/{LoginPage-2S4fAPpI.js → LoginPage-W91noLcb.js} +1 -1
  25. package/web-app/dist/assets/{MagicPage-CMxPqcqC.js → MagicPage-DdIXXbgO.js} +1 -1
  26. package/web-app/dist/assets/{MetricsPage-SS7Oj6Pk.js → MetricsPage-FhjqTGMb.js} +1 -1
  27. package/web-app/dist/assets/{NotFoundPage-BZXdsLzP.js → NotFoundPage-B7gXwg03.js} +1 -1
  28. package/web-app/dist/assets/{ProjectPage-CqpoFnd2.js → ProjectPage-DMP7-lF9.js} +3 -3
  29. package/web-app/dist/assets/{ProjectsPage-BUth71nx.js → ProjectsPage-BEv3tKH5.js} +1 -1
  30. package/web-app/dist/assets/{SettingsPage-BdmBARGN.js → SettingsPage-BahxJ6e-.js} +1 -1
  31. package/web-app/dist/assets/{ShowcasePage-BuPyRdsp.js → ShowcasePage-B8ZK_eFm.js} +1 -1
  32. package/web-app/dist/assets/{SystemSettingsPage-nWi3Y49V.js → SystemSettingsPage-i_ua6JYr.js} +1 -1
  33. package/web-app/dist/assets/{TeamsPage-Bbzd2c18.js → TeamsPage-BxKtdgg8.js} +1 -1
  34. package/web-app/dist/assets/{TemplatesPage-CFaCEASB.js → TemplatesPage-B74RvPRL.js} +1 -1
  35. package/web-app/dist/assets/{TerminalOutput-CwMRjG7N.js → TerminalOutput-CusdIPW5.js} +1 -1
  36. package/web-app/dist/assets/{activity-DSavuwyT.js → activity-BzYTwsTy.js} +1 -1
  37. package/web-app/dist/assets/{bell-BlhMsQEo.js → bell-DH9nf5zB.js} +1 -1
  38. package/web-app/dist/assets/{bot-CNA95IKJ.js → bot-HoIZK7eB.js} +1 -1
  39. package/web-app/dist/assets/{check-DOrNSoDM.js → check-Bg2o382k.js} +1 -1
  40. package/web-app/dist/assets/{chevron-left-B2USiPOZ.js → chevron-left-v-IUHzhm.js} +1 -1
  41. package/web-app/dist/assets/{circle-alert-Bo5f9KMG.js → circle-alert-G3MHxw9O.js} +1 -1
  42. package/web-app/dist/assets/{clock-rzxwnuLM.js → clock-CR8oME3b.js} +1 -1
  43. package/web-app/dist/assets/{cloud-DjFY4qb6.js → cloud-FNlrpEVb.js} +1 -1
  44. package/web-app/dist/assets/{code-xml-DoEdsIKW.js → code-xml-THwmjPSI.js} +1 -1
  45. package/web-app/dist/assets/{database-G9bBiLg8.js → database-DGS4lCam.js} +1 -1
  46. package/web-app/dist/assets/{dollar-sign-bvddxRmc.js → dollar-sign-D1D7210y.js} +1 -1
  47. package/web-app/dist/assets/{file-code-corner-B4pgv1xC.js → file-code-corner-BEZS0NVh.js} +1 -1
  48. package/web-app/dist/assets/{file-plus-Mr4Q0b_Z.js → file-plus-slraMnC-.js} +1 -1
  49. package/web-app/dist/assets/{globe-BM6uvx4Q.js → globe-Ckj7P6MF.js} +1 -1
  50. package/web-app/dist/assets/{hammer-Dbg_jerN.js → hammer-BzjDp6gZ.js} +1 -1
  51. package/web-app/dist/assets/{index-JLmXzxqi.js → index-Dtrn_88z.js} +2 -2
  52. package/web-app/dist/assets/{layers-duNSn4MK.js → layers-mUcW7Lcv.js} +1 -1
  53. package/web-app/dist/assets/{loader-circle-BTy_2p55.js → loader-circle-CwrHVSiy.js} +1 -1
  54. package/web-app/dist/assets/{lock-Da91KLZZ.js → lock-a4yooVvD.js} +1 -1
  55. package/web-app/dist/assets/{package-CUrUe6vT.js → package-H3syThe6.js} +1 -1
  56. package/web-app/dist/assets/{plus-CyFRqzGH.js → plus-R5BxuvRb.js} +1 -1
  57. package/web-app/dist/assets/{refresh-cw-YelQ45nx.js → refresh-cw-CA64KPjz.js} +1 -1
  58. package/web-app/dist/assets/{rotate-ccw-BIU7xZyT.js → rotate-ccw-DbYX6fj6.js} +1 -1
  59. package/web-app/dist/assets/{scroll-text-C88oxteF.js → scroll-text-DSAnxmlo.js} +1 -1
  60. package/web-app/dist/assets/{server-X3H7bWFy.js → server--M5B5u1M.js} +1 -1
  61. package/web-app/dist/assets/{shield-alert-gpP8kh02.js → shield-alert-DL9xNxeK.js} +1 -1
  62. package/web-app/dist/assets/{thumbs-up-BxwJ7kT_.js → thumbs-up-2MLPuEWk.js} +1 -1
  63. package/web-app/dist/assets/{trash-2-M7R3o6Ds.js → trash-2-CjFzSYHy.js} +1 -1
  64. package/web-app/dist/assets/{trending-down-DYXErqkJ.js → trending-down-BVWPFfUX.js} +1 -1
  65. package/web-app/dist/assets/{trending-up-CqZThz26.js → trending-up-CwcHClXI.js} +1 -1
  66. package/web-app/dist/assets/{upload-BZPVsx2j.js → upload-ODPqesIu.js} +1 -1
  67. package/web-app/dist/assets/{usePolling-CFDpdEmJ.js → usePolling-5s1fbj3D.js} +1 -1
  68. package/web-app/dist/assets/{user-CG96I_PT.js → user-B249yxJV.js} +1 -1
  69. 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.0
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.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v10.1.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 10.1.0
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())