@intutic/sync-daemon 2.9.1 → 3.0.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/dist/disconnect/harnesses.d.ts.map +1 -1
- package/dist/disconnect/harnesses.js +3 -2
- package/dist/disconnect/harnesses.js.map +1 -1
- package/dist/harness/antigravityCliHooks.js +3 -3
- package/dist/harness/antigravityCliHooks.js.map +1 -1
- package/dist/harness/antigravityHooks.d.ts +8 -0
- package/dist/harness/antigravityHooks.d.ts.map +1 -1
- package/dist/harness/antigravityHooks.js +27 -5
- package/dist/harness/antigravityHooks.js.map +1 -1
- package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
- package/dist/harness/claudeCodeHooks.js +58 -16
- package/dist/harness/claudeCodeHooks.js.map +1 -1
- package/dist/harness/clineHooks.d.ts.map +1 -1
- package/dist/harness/clineHooks.js +4 -2
- package/dist/harness/clineHooks.js.map +1 -1
- package/dist/harness/codexHooks.d.ts.map +1 -1
- package/dist/harness/codexHooks.js +4 -2
- package/dist/harness/codexHooks.js.map +1 -1
- package/dist/harness/cursorHooks.d.ts.map +1 -1
- package/dist/harness/cursorHooks.js +8 -3
- package/dist/harness/cursorHooks.js.map +1 -1
- package/dist/harness/gateBody.d.ts +177 -4
- package/dist/harness/gateBody.d.ts.map +1 -1
- package/dist/harness/gateBody.js +1658 -26
- package/dist/harness/gateBody.js.map +1 -1
- package/dist/harness/githubCopilotHooks.js +3 -3
- package/dist/harness/githubCopilotHooks.js.map +1 -1
- package/dist/harness/gooseHooks.d.ts.map +1 -1
- package/dist/harness/gooseHooks.js +8 -1
- package/dist/harness/gooseHooks.js.map +1 -1
- package/dist/harness/grokHooks.js +2 -2
- package/dist/harness/hermesHooks.js +1 -1
- package/dist/harness/hermesHooks.js.map +1 -1
- package/dist/harness/museHooks.d.ts.map +1 -1
- package/dist/harness/museHooks.js +4 -2
- package/dist/harness/museHooks.js.map +1 -1
- package/dist/harness/n8nHooks.js +1 -1
- package/dist/harness/openCodeHooks.d.ts.map +1 -1
- package/dist/harness/openCodeHooks.js +32 -8
- package/dist/harness/openCodeHooks.js.map +1 -1
- package/dist/harness/openclawHooks.d.ts.map +1 -1
- package/dist/harness/openclawHooks.js +19 -8
- package/dist/harness/openclawHooks.js.map +1 -1
- package/dist/harness/piHooks.js +7 -7
- package/dist/harness/windsurfHooks.js +2 -2
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/approvedBypasses.d.ts +17 -4
- package/dist/lib/approvedBypasses.d.ts.map +1 -1
- package/dist/lib/approvedBypasses.js +17 -4
- package/dist/lib/approvedBypasses.js.map +1 -1
- package/dist/lib/approvedPlans.d.ts +73 -0
- package/dist/lib/approvedPlans.d.ts.map +1 -0
- package/dist/lib/approvedPlans.js +124 -0
- package/dist/lib/approvedPlans.js.map +1 -0
- package/dist/lib/decideLogs.d.ts +46 -0
- package/dist/lib/decideLogs.d.ts.map +1 -0
- package/dist/lib/decideLogs.js +160 -0
- package/dist/lib/decideLogs.js.map +1 -0
- package/dist/lib/policySnapshot.d.ts +143 -3
- package/dist/lib/policySnapshot.d.ts.map +1 -1
- package/dist/lib/policySnapshot.js +480 -14
- package/dist/lib/policySnapshot.js.map +1 -1
- package/dist/lib/refusalDetailPy.d.ts +11 -0
- package/dist/lib/refusalDetailPy.d.ts.map +1 -0
- package/dist/lib/refusalDetailPy.js +109 -0
- package/dist/lib/refusalDetailPy.js.map +1 -0
- package/dist/lib/ruleRecordPy.d.ts +11 -0
- package/dist/lib/ruleRecordPy.d.ts.map +1 -0
- package/dist/lib/ruleRecordPy.js +277 -0
- package/dist/lib/ruleRecordPy.js.map +1 -0
- package/dist/lib/scopeMatchPy.d.ts +11 -0
- package/dist/lib/scopeMatchPy.d.ts.map +1 -0
- package/dist/lib/scopeMatchPy.js +278 -0
- package/dist/lib/scopeMatchPy.js.map +1 -0
- package/dist/lib/testOutcomePy.d.ts +11 -0
- package/dist/lib/testOutcomePy.d.ts.map +1 -0
- package/dist/lib/testOutcomePy.js +371 -0
- package/dist/lib/testOutcomePy.js.map +1 -0
- package/dist/skillWriter.d.ts +1 -1
- package/dist/skillWriter.d.ts.map +1 -1
- package/dist/skillWriter.js +7 -0
- package/dist/skillWriter.js.map +1 -1
- package/dist/syncCycle.d.ts +15 -2
- package/dist/syncCycle.d.ts.map +1 -1
- package/dist/syncCycle.js +15 -3
- package/dist/syncCycle.js.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scope and plan match in Python, for the bash gates (their extractor already requires
|
|
3
|
+
* python3), emitted after the phrase matcher (`phrasesPy.ts`) in `GATE_PY_LIB`.
|
|
4
|
+
*
|
|
5
|
+
* Byte-identical to `packages/intutic-clawde/intutic_clawde/gate/scope_match.py`,
|
|
6
|
+
* which a test compares; edit that file and copy it here. It is the
|
|
7
|
+
* transliteration of `@intutic/shared-types`'s scopeMatch.ts, and both run the
|
|
8
|
+
* shared vectors. No backtick or dollar-brace, which this copy cannot hold.
|
|
9
|
+
*/
|
|
10
|
+
export const SCOPE_MATCH_PY_SOURCE = String.raw `"""Scope and plan semantics, ported from the proxy so every surface answers
|
|
11
|
+
"is this change inside the scope?" and "is this run on its plan?" the same way
|
|
12
|
+
.
|
|
13
|
+
|
|
14
|
+
A transliteration of packages/shared-types/src/scopeMatch.ts; read that
|
|
15
|
+
module's docstring for each function's port of origin (detectors.rs,
|
|
16
|
+
sops.rs, manifest.rs). Every port runs
|
|
17
|
+
packages/shared-types/fixtures/scope-plan-vectors.json.
|
|
18
|
+
|
|
19
|
+
The bash hook gates emit this file after the phrase matcher's (phrases.py),
|
|
20
|
+
which is_file_write_call calls: imported here as a module, and found in the
|
|
21
|
+
emitting script's scope when it is not one. Otherwise the standard library
|
|
22
|
+
only, and no backtick or dollar-brace, which a TypeScript copy cannot hold.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from .phrases import has_phrase, phrase_text
|
|
27
|
+
except (ImportError, KeyError): # embedded in a bash gate (exec without __name__), beside the phrase matcher's source
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
PLAN_DEVIATION_TOLERANCE = 0.4
|
|
31
|
+
NARROWED_TO_NOTHING = "<none: workspace list disjoint from org ceiling>"
|
|
32
|
+
|
|
33
|
+
SCOPE_WRITE_TOOLS = ("write", "write_file", "writefile", "create_file", "createfile")
|
|
34
|
+
SCOPE_EDIT_TOOLS = ("edit", "edit_file", "editfile", "multiedit", "apply_patch", "str_replace", "notebookedit")
|
|
35
|
+
SCOPE_DELETE_TOOLS = ("delete_file", "deletefile", "remove_file", "rm_file")
|
|
36
|
+
SCOPE_MOVE_TOOLS = ("move_file", "movefile", "rename_file", "rename")
|
|
37
|
+
SCOPE_EDITOR_TOOLS = ("str_replace_editor", "text_editor", "texteditor", "str_replace_based_edit_tool")
|
|
38
|
+
SCOPE_PATH_KEYS = ("file_path", "filepath", "path", "filename", "target_file", "notebook_path")
|
|
39
|
+
SCOPE_COMMAND_KEYS = ("command", "cmd", "script")
|
|
40
|
+
SCOPE_SHELL_TOOLS = ("bash", "shell", "run_command", "runcommand", "execute_command", "run_shell_command",
|
|
41
|
+
"terminal", "execute", "exec")
|
|
42
|
+
SCOPE_READ_TOOLS = ("read", "readfile", "view", "cat", "open_file")
|
|
43
|
+
SCOPE_MOVE_FROM_KEYS = ("source", "source_path", "from", "old_path")
|
|
44
|
+
SCOPE_MOVE_TO_KEYS = ("destination", "destination_path", "to", "new_path")
|
|
45
|
+
SCOPE_TARGET_MAX_BYTES = 512
|
|
46
|
+
PLAN_EXEMPT_TOOLS = ("read", "readfile", "view", "cat", "open_file", "glob", "grep", "ls", "list_dir", "search",
|
|
47
|
+
"todowrite")
|
|
48
|
+
FILE_WRITE_SHELL_PHRASES = ("sed -i", "perl -i", "perl -pi", "git apply", "git am", "git checkout", "git switch",
|
|
49
|
+
"git restore", "git reset", "git merge", "git pull", "git rebase", "git cherry-pick",
|
|
50
|
+
"git revert", "git stash pop", "git stash apply", "patch -p")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ascii_lower(s):
|
|
54
|
+
return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in s)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _tool_is(name, group):
|
|
58
|
+
lower = name.lower()
|
|
59
|
+
return any(lower == t or lower.endswith(t) for t in group)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _keyed(inp, keys):
|
|
63
|
+
if not isinstance(inp, dict):
|
|
64
|
+
return None
|
|
65
|
+
for k in keys:
|
|
66
|
+
v = inp.get(k)
|
|
67
|
+
if isinstance(v, str) and v != "":
|
|
68
|
+
return v
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _utf8_len(ch):
|
|
73
|
+
cp = ord(ch)
|
|
74
|
+
return 1 if cp < 0x80 else 2 if cp < 0x800 else 3 if cp < 0x10000 else 4
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _clip(s):
|
|
78
|
+
total = 0
|
|
79
|
+
for i, ch in enumerate(s):
|
|
80
|
+
n = _utf8_len(ch)
|
|
81
|
+
if total + n > SCOPE_TARGET_MAX_BYTES:
|
|
82
|
+
return s[:i] + "…"
|
|
83
|
+
total += n
|
|
84
|
+
return s
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _norm_scope(s):
|
|
88
|
+
t = s
|
|
89
|
+
while t[:2] == "./":
|
|
90
|
+
t = t[2:]
|
|
91
|
+
t = t.lstrip("/").rstrip("/")
|
|
92
|
+
return _ascii_lower(t)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_within_scope(target, scopes):
|
|
96
|
+
"""Segment-prefix match of target against scopes, ignoring ASCII case and leading ./ or /."""
|
|
97
|
+
t = _norm_scope(target)
|
|
98
|
+
for raw in scopes:
|
|
99
|
+
s = _norm_scope(raw)
|
|
100
|
+
if s == "" or t == s or t[:len(s) + 1] == s + "/":
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def sop_applies_to_role(roles, role):
|
|
106
|
+
if not roles:
|
|
107
|
+
return True
|
|
108
|
+
if role == "":
|
|
109
|
+
return False
|
|
110
|
+
return _ascii_lower(role) in roles
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def collect_scope_paths(sops, role):
|
|
114
|
+
"""The path scope in force for role (sops.rs collect_scope_paths), org ceiling included."""
|
|
115
|
+
workspace, org = [], []
|
|
116
|
+
for s in sops:
|
|
117
|
+
if not sop_applies_to_role(s["roles"], role):
|
|
118
|
+
continue
|
|
119
|
+
(org if s["scope"] == "org" else workspace).extend(s["scopePaths"])
|
|
120
|
+
if not org:
|
|
121
|
+
out = workspace
|
|
122
|
+
elif not workspace:
|
|
123
|
+
out = org
|
|
124
|
+
else:
|
|
125
|
+
out = [w for w in workspace if is_within_scope(w, org)] or [NARROWED_TO_NOTHING]
|
|
126
|
+
return sorted(set(out))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def collect_plan_steps(sops, role):
|
|
130
|
+
"""The plan in force for role (sops.rs collect_plan_steps), org ceiling included."""
|
|
131
|
+
workspace, org = [], []
|
|
132
|
+
for s in sops:
|
|
133
|
+
if not sop_applies_to_role(s["roles"], role):
|
|
134
|
+
continue
|
|
135
|
+
(org if s["scope"] == "org" else workspace).extend(s["planSteps"])
|
|
136
|
+
if not org:
|
|
137
|
+
return workspace
|
|
138
|
+
if not workspace:
|
|
139
|
+
return org
|
|
140
|
+
allowed = {_ascii_lower(o) for o in org}
|
|
141
|
+
return [w for w in workspace if _ascii_lower(w) in allowed] or [NARROWED_TO_NOTHING]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def plan_off_steps(sequence, plan_steps, tolerance):
|
|
145
|
+
"""{"offPlan", "ratio"}: the off-plan names, sorted and deduplicated, when their share is over tolerance."""
|
|
146
|
+
if not plan_steps or not sequence:
|
|
147
|
+
return {"offPlan": [], "ratio": 0}
|
|
148
|
+
plan = {_ascii_lower(p) for p in plan_steps}
|
|
149
|
+
off = [t for t in sequence if _ascii_lower(t) not in plan]
|
|
150
|
+
if not off:
|
|
151
|
+
return {"offPlan": [], "ratio": 0}
|
|
152
|
+
ratio = len(off) / len(sequence)
|
|
153
|
+
if ratio <= tolerance:
|
|
154
|
+
return {"offPlan": [], "ratio": ratio}
|
|
155
|
+
return {"offPlan": sorted(set(off)), "ratio": min(1, ratio)}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def change_targets(tool, inp):
|
|
159
|
+
"""The mutations one tool call makes, as the proxy's change manifest records them."""
|
|
160
|
+
if _tool_is(tool, SCOPE_MOVE_TOOLS):
|
|
161
|
+
src = _keyed(inp, SCOPE_MOVE_FROM_KEYS)
|
|
162
|
+
dst = _keyed(inp, SCOPE_MOVE_TO_KEYS)
|
|
163
|
+
if src is not None or dst is not None:
|
|
164
|
+
return [{"op": "move", "target": _clip(t)} for t in (src, dst) if t is not None]
|
|
165
|
+
op = None
|
|
166
|
+
if _tool_is(tool, SCOPE_EDITOR_TOOLS):
|
|
167
|
+
command = inp.get("command") if isinstance(inp, dict) else None
|
|
168
|
+
command = _ascii_lower(command) if isinstance(command, str) else ""
|
|
169
|
+
if command == "view":
|
|
170
|
+
return []
|
|
171
|
+
op = "write" if command == "create" else "edit"
|
|
172
|
+
elif _tool_is(tool, SCOPE_WRITE_TOOLS):
|
|
173
|
+
op = "write"
|
|
174
|
+
elif _tool_is(tool, SCOPE_EDIT_TOOLS):
|
|
175
|
+
op = "edit"
|
|
176
|
+
elif _tool_is(tool, SCOPE_DELETE_TOOLS):
|
|
177
|
+
op = "delete"
|
|
178
|
+
if op is None:
|
|
179
|
+
return []
|
|
180
|
+
target = _keyed(inp, SCOPE_PATH_KEYS)
|
|
181
|
+
return [] if target is None else [{"op": op, "target": _clip(target)}]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _diff_path(raw, prefix):
|
|
185
|
+
p = raw
|
|
186
|
+
tab = p.find("\t")
|
|
187
|
+
if tab != -1:
|
|
188
|
+
p = p[:tab]
|
|
189
|
+
p = p.strip(" ")
|
|
190
|
+
if len(p) >= 2 and p[0] == '"' and p[-1] == '"':
|
|
191
|
+
p = p[1:-1]
|
|
192
|
+
if p in ("/dev/null", ""):
|
|
193
|
+
return None
|
|
194
|
+
if prefix and p[:len(prefix)] == prefix:
|
|
195
|
+
p = p[len(prefix):]
|
|
196
|
+
return p or None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def diff_changed_paths(diff):
|
|
200
|
+
"""The paths a diff changes, in the order first seen, each once."""
|
|
201
|
+
out = []
|
|
202
|
+
|
|
203
|
+
def add(p):
|
|
204
|
+
if p is not None and p not in out:
|
|
205
|
+
out.append(p)
|
|
206
|
+
|
|
207
|
+
for raw in diff.split("\n"):
|
|
208
|
+
line = raw[:-1] if raw.endswith("\r") else raw
|
|
209
|
+
if line[:11] == "diff --git ":
|
|
210
|
+
rest = line[11:]
|
|
211
|
+
if rest[:2] != "a/":
|
|
212
|
+
continue
|
|
213
|
+
first = rest.find(" b/")
|
|
214
|
+
if first == -1:
|
|
215
|
+
continue
|
|
216
|
+
if rest.find(" b/", first + 1) == -1:
|
|
217
|
+
add(_diff_path(rest[:first], "a/"))
|
|
218
|
+
add(_diff_path(rest[first + 1:], "b/"))
|
|
219
|
+
elif (len(rest) - 5) % 2 == 0:
|
|
220
|
+
n = (len(rest) - 5) // 2
|
|
221
|
+
a = rest[2:2 + n]
|
|
222
|
+
if rest[2 + n:5 + n] == " b/" and rest[5 + n:] == a:
|
|
223
|
+
add(_diff_path(a, ""))
|
|
224
|
+
elif line[:4] == "--- ":
|
|
225
|
+
add(_diff_path(line[4:], "a/"))
|
|
226
|
+
elif line[:4] == "+++ ":
|
|
227
|
+
add(_diff_path(line[4:], "b/"))
|
|
228
|
+
elif line[:12] == "rename from ":
|
|
229
|
+
add(_diff_path(line[12:], ""))
|
|
230
|
+
elif line[:10] == "rename to ":
|
|
231
|
+
add(_diff_path(line[10:], ""))
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _normalise_path(p):
|
|
236
|
+
parts = []
|
|
237
|
+
for seg in p.split("/"):
|
|
238
|
+
if seg in ("", "."):
|
|
239
|
+
continue
|
|
240
|
+
if seg == "..":
|
|
241
|
+
if parts:
|
|
242
|
+
parts.pop()
|
|
243
|
+
else:
|
|
244
|
+
parts.append(seg)
|
|
245
|
+
return "/" + "/".join(parts)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def scope_relative_path(target, cwd, root):
|
|
249
|
+
"""target relative to the workspace root, or None when outside it, under ~, or unresolvable."""
|
|
250
|
+
if target == "" or target[:1] == "~" or cwd[:1] != "/" or root[:1] != "/":
|
|
251
|
+
return None
|
|
252
|
+
absolute = _normalise_path(target if target[:1] == "/" else cwd + "/" + target)
|
|
253
|
+
base = _normalise_path(root)
|
|
254
|
+
if absolute == base:
|
|
255
|
+
return ""
|
|
256
|
+
head = "/" if base == "/" else base + "/"
|
|
257
|
+
return absolute[len(head):] if absolute[:len(head)] == head else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_plan_exempt_tool(tool):
|
|
261
|
+
"""Whether plan_approval: lets tool run before approval: a PLAN_EXEMPT_TOOLS name, bare or mcp__<server>__<name>."""
|
|
262
|
+
lower = tool.lower()
|
|
263
|
+
return any(lower == t or lower[-(len(t) + 2):] == "__" + t for t in PLAN_EXEMPT_TOOLS)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_file_write_call(tool, inp):
|
|
267
|
+
"""Whether the call changed files: a mutation, or a shell command with a FILE_WRITE_SHELL_PHRASES phrase."""
|
|
268
|
+
if change_targets(tool, inp):
|
|
269
|
+
return True
|
|
270
|
+
if not _tool_is(tool, SCOPE_SHELL_TOOLS):
|
|
271
|
+
return False
|
|
272
|
+
command = _keyed(inp, SCOPE_COMMAND_KEYS)
|
|
273
|
+
if command is None:
|
|
274
|
+
return False
|
|
275
|
+
words = phrase_text(command)
|
|
276
|
+
return any(has_phrase(words, p) for p in FILE_WRITE_SHELL_PHRASES)
|
|
277
|
+
`;
|
|
278
|
+
//# sourceMappingURL=scopeMatchPy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scopeMatchPy.js","sourceRoot":"","sources":["../../src/lib/scopeMatchPy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2Q9C,CAAA"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test outcome decision and the per-session outcome state in Python, for the bash gates (their extractor already requires
|
|
3
|
+
* python3), emitted after the phrase matcher (`phrasesPy.ts`) in `GATE_PY_LIB`.
|
|
4
|
+
*
|
|
5
|
+
* Byte-identical to `packages/intutic-clawde/intutic_clawde/gate/test_outcome.py`,
|
|
6
|
+
* which a test compares; edit that file and copy it here. It is the
|
|
7
|
+
* transliteration of `@intutic/shared-types`'s testOutcome.ts, and both run the
|
|
8
|
+
* shared vectors. No backtick or dollar-brace, which this copy cannot hold.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TEST_OUTCOME_PY_SOURCE: string;
|
|
11
|
+
//# sourceMappingURL=testOutcomePy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testOutcomePy.d.ts","sourceRoot":"","sources":["../../src/lib/testOutcomePy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAwWlC,CAAA"}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test outcome decision and the per-session outcome state in Python, for the bash gates (their extractor already requires
|
|
3
|
+
* python3), emitted after the phrase matcher (`phrasesPy.ts`) in `GATE_PY_LIB`.
|
|
4
|
+
*
|
|
5
|
+
* Byte-identical to `packages/intutic-clawde/intutic_clawde/gate/test_outcome.py`,
|
|
6
|
+
* which a test compares; edit that file and copy it here. It is the
|
|
7
|
+
* transliteration of `@intutic/shared-types`'s testOutcome.ts, and both run the
|
|
8
|
+
* shared vectors. No backtick or dollar-brace, which this copy cannot hold.
|
|
9
|
+
*/
|
|
10
|
+
export const TEST_OUTCOME_PY_SOURCE = String.raw `"""Did the tests pass? The deciding function behind the qualified ordering rule
|
|
11
|
+
requires_before: action:run_tests[ok] -> action:deploy, and the
|
|
12
|
+
per-session state a gate keeps of it.
|
|
13
|
+
|
|
14
|
+
A transliteration of packages/shared-types/src/testOutcome.ts; read that
|
|
15
|
+
module's docstring for the steps. In short, decide_test_outcome says failed
|
|
16
|
+
for a non-zero exit code, an error flag, a false success flag, a runner
|
|
17
|
+
summary with failures, or a summary saying no tests ran; ok for exit code 0,
|
|
18
|
+
or, with no exit code, a summary that counts a pass; and unknown otherwise,
|
|
19
|
+
which leaves a qualified rule unmet as a failure does.
|
|
20
|
+
|
|
21
|
+
Every port runs packages/shared-types/fixtures/test-outcome-vectors.json. The
|
|
22
|
+
bash hook gates emit this file verbatim, so it must stay self-contained: the
|
|
23
|
+
standard library only, no module state beyond the constants below, and no
|
|
24
|
+
backtick or dollar-brace, which a TypeScript copy cannot hold.
|
|
25
|
+
|
|
26
|
+
Inputs and states are plain dicts with the TypeScript field names
|
|
27
|
+
(exitCode, isError, success, output; {"v": 1, "outcomes":
|
|
28
|
+
{...}}), so a gate can read and write the same state file.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
|
|
34
|
+
OUTCOME_TOKENS = ("action:run_tests",)
|
|
35
|
+
OUTCOME_QUALIFIERS = ("ok",)
|
|
36
|
+
TEST_OUTPUT_TAIL_BYTES = 65536
|
|
37
|
+
TEST_OUTPUT_LINE_BYTES = 1024
|
|
38
|
+
TEST_OUTPUT_JSON_MAX_CHARS = 1048576
|
|
39
|
+
OUTCOME_STATE_TTL_MS = 86400000
|
|
40
|
+
_MAX_SAFE_INTEGER = 9007199254740991
|
|
41
|
+
|
|
42
|
+
_ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
43
|
+
_EXIT_CODE = re.compile(r"exit code:? (-?[0-9]{1,9})\Z", re.IGNORECASE | re.ASCII)
|
|
44
|
+
_EXIT_STATUS = re.compile(r"exit status ([0-9]{1,9})\Z")
|
|
45
|
+
_EXITED = re.compile(r"(?:Process|Command) exited with code (-?[0-9]{1,9})")
|
|
46
|
+
_COUNT = re.compile(r"([0-9]{1,9}) ([a-z]+)")
|
|
47
|
+
_PYTEST_NONE = re.compile(r"=+ no tests ran")
|
|
48
|
+
_PYTEST = re.compile(r"=+ (.+) in [0-9.]+s.* =+\Z")
|
|
49
|
+
_JEST = re.compile(r"Tests:[ \t]+(.+)\Z")
|
|
50
|
+
_VITEST = re.compile(r"[ \t]*Tests[ \t]+(.+)\Z")
|
|
51
|
+
_GO_OK = re.compile(r"ok[ \t]+[^ \t]+")
|
|
52
|
+
_GO_FAIL = re.compile(r"FAIL(?:[ \t]|\Z)")
|
|
53
|
+
_GO_FAIL_TEST = re.compile(r"--- FAIL:")
|
|
54
|
+
_CARGO = re.compile(r"test result: (ok|FAILED)\. ([0-9]{1,9}) passed; ([0-9]{1,9}) failed;")
|
|
55
|
+
_MOCHA_PASS = re.compile(r"[ \t]*([0-9]{1,9}) passing")
|
|
56
|
+
_MOCHA_FAIL = re.compile(r"[ \t]*([0-9]{1,9}) failing")
|
|
57
|
+
_RSPEC = re.compile(r"([0-9]{1,9}) examples?, ([0-9]{1,9}) failures?")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _utf8_len(ch):
|
|
61
|
+
cp = ord(ch)
|
|
62
|
+
if cp < 0x80:
|
|
63
|
+
return 1
|
|
64
|
+
if cp < 0x800:
|
|
65
|
+
return 2
|
|
66
|
+
if cp < 0x10000:
|
|
67
|
+
return 3
|
|
68
|
+
return 4
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _utf8_tail(s, max_bytes):
|
|
72
|
+
"""The last max_bytes bytes of s as UTF-8, starting on a character."""
|
|
73
|
+
total = 0
|
|
74
|
+
i = len(s)
|
|
75
|
+
while i > 0:
|
|
76
|
+
n = _utf8_len(s[i - 1])
|
|
77
|
+
if total + n > max_bytes:
|
|
78
|
+
break
|
|
79
|
+
total += n
|
|
80
|
+
i -= 1
|
|
81
|
+
return s[i:]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _utf8_head(s, max_bytes):
|
|
85
|
+
"""The first max_bytes bytes of s as UTF-8, ending on a character."""
|
|
86
|
+
total = 0
|
|
87
|
+
i = 0
|
|
88
|
+
while i < len(s):
|
|
89
|
+
n = _utf8_len(s[i])
|
|
90
|
+
if total + n > max_bytes:
|
|
91
|
+
break
|
|
92
|
+
total += n
|
|
93
|
+
i += 1
|
|
94
|
+
return s[:i]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _lines(text):
|
|
98
|
+
"""The lines of text, each cut to 1 KiB, without carriage returns or ANSI sequences."""
|
|
99
|
+
return [_ANSI.sub("", _utf8_head(raw, TEST_OUTPUT_LINE_BYTES).replace("\r", "")) for raw in text.split("\n")]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _integer_or_none(v):
|
|
103
|
+
"""A JSON integer exit code, or None."""
|
|
104
|
+
if isinstance(v, bool):
|
|
105
|
+
return None
|
|
106
|
+
if isinstance(v, int) and abs(v) <= _MAX_SAFE_INTEGER:
|
|
107
|
+
return v
|
|
108
|
+
if isinstance(v, float) and v.is_integer() and abs(v) <= _MAX_SAFE_INTEGER:
|
|
109
|
+
return int(v)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_exit_code(output):
|
|
114
|
+
"""The exit code a run's text reports, or None. See testOutcome.ts extractExitCode."""
|
|
115
|
+
if not isinstance(output, str) or output == "":
|
|
116
|
+
return None
|
|
117
|
+
trimmed = output.strip()
|
|
118
|
+
if len(trimmed) <= TEST_OUTPUT_JSON_MAX_CHARS and trimmed[:1] == "{" and trimmed[-1:] == "}":
|
|
119
|
+
try:
|
|
120
|
+
parsed = json.loads(trimmed)
|
|
121
|
+
except ValueError:
|
|
122
|
+
parsed = None
|
|
123
|
+
if isinstance(parsed, dict):
|
|
124
|
+
meta = parsed.get("metadata")
|
|
125
|
+
if isinstance(meta, dict):
|
|
126
|
+
m = _integer_or_none(meta.get("exit_code"))
|
|
127
|
+
if m is not None:
|
|
128
|
+
return m
|
|
129
|
+
for key in ("exit_code", "exitCode"):
|
|
130
|
+
v = _integer_or_none(parsed.get(key))
|
|
131
|
+
if v is not None:
|
|
132
|
+
return v
|
|
133
|
+
head = _utf8_head(output, TEST_OUTPUT_TAIL_BYTES)
|
|
134
|
+
parts = [head] if len(head) == len(output) else [head, _utf8_tail(output[len(head):], TEST_OUTPUT_TAIL_BYTES)]
|
|
135
|
+
for part in parts:
|
|
136
|
+
for raw in _lines(part):
|
|
137
|
+
line = raw.lstrip(" \t").rstrip(" \t")
|
|
138
|
+
m = _EXIT_CODE.match(line) or _EXIT_STATUS.match(line) or _EXITED.match(line)
|
|
139
|
+
if m:
|
|
140
|
+
return int(m.group(1))
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _counts(text):
|
|
145
|
+
"""'N word' pieces of a summary, split on ',' and '|': the count of each word."""
|
|
146
|
+
counts = {}
|
|
147
|
+
for piece in re.split(r"[,|]", text):
|
|
148
|
+
m = _COUNT.match(piece.lstrip(" \t"))
|
|
149
|
+
if m:
|
|
150
|
+
counts[m.group(2)] = counts.get(m.group(2), 0) + int(m.group(1))
|
|
151
|
+
return counts
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parse_test_summaries(output):
|
|
155
|
+
"""The runner summaries in the last 64 KiB of output, in order (go and mocha folded into one each)."""
|
|
156
|
+
if not isinstance(output, str) or output == "":
|
|
157
|
+
return []
|
|
158
|
+
out = []
|
|
159
|
+
go = None
|
|
160
|
+
mocha = None
|
|
161
|
+
for line in _lines(_utf8_tail(output, TEST_OUTPUT_TAIL_BYTES)):
|
|
162
|
+
if _PYTEST_NONE.match(line):
|
|
163
|
+
out.append({"runner": "pytest", "passed": 0, "failed": 0, "noTests": True})
|
|
164
|
+
continue
|
|
165
|
+
m = _PYTEST.match(line)
|
|
166
|
+
if m:
|
|
167
|
+
c = _counts(m.group(1))
|
|
168
|
+
if not any(k in c for k in ("passed", "failed", "error", "errors")):
|
|
169
|
+
continue
|
|
170
|
+
out.append({"runner": "pytest", "passed": c.get("passed", 0),
|
|
171
|
+
"failed": c.get("failed", 0) + c.get("error", 0) + c.get("errors", 0),
|
|
172
|
+
"noTests": c.get("passed") == 0})
|
|
173
|
+
continue
|
|
174
|
+
m = _JEST.match(line)
|
|
175
|
+
if m:
|
|
176
|
+
c = _counts(m.group(1))
|
|
177
|
+
out.append({"runner": "jest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
|
|
178
|
+
"noTests": c.get("passed") == 0 or c.get("total") == 0})
|
|
179
|
+
continue
|
|
180
|
+
m = _VITEST.match(line)
|
|
181
|
+
if m:
|
|
182
|
+
c = _counts(m.group(1))
|
|
183
|
+
if "passed" not in c and "failed" not in c:
|
|
184
|
+
continue
|
|
185
|
+
out.append({"runner": "vitest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
|
|
186
|
+
"noTests": c.get("passed") == 0})
|
|
187
|
+
continue
|
|
188
|
+
if _GO_OK.match(line):
|
|
189
|
+
if go is None:
|
|
190
|
+
go = {"runner": "go", "passed": 0, "failed": 0, "noTests": False}
|
|
191
|
+
out.append(go)
|
|
192
|
+
go["passed"] += 1
|
|
193
|
+
continue
|
|
194
|
+
if _GO_FAIL.match(line) or _GO_FAIL_TEST.match(line):
|
|
195
|
+
if go is None:
|
|
196
|
+
go = {"runner": "go", "passed": 0, "failed": 0, "noTests": False}
|
|
197
|
+
out.append(go)
|
|
198
|
+
go["failed"] += 1
|
|
199
|
+
continue
|
|
200
|
+
m = _CARGO.match(line)
|
|
201
|
+
if m:
|
|
202
|
+
passed, failed = int(m.group(2)), int(m.group(3))
|
|
203
|
+
out.append({"runner": "cargo", "passed": passed,
|
|
204
|
+
"failed": 1 if m.group(1) == "FAILED" and failed == 0 else failed,
|
|
205
|
+
"noTests": passed == 0})
|
|
206
|
+
continue
|
|
207
|
+
m = _MOCHA_PASS.match(line)
|
|
208
|
+
if m:
|
|
209
|
+
if mocha is None:
|
|
210
|
+
mocha = {"runner": "mocha", "passed": 0, "failed": 0, "noTests": False}
|
|
211
|
+
out.append(mocha)
|
|
212
|
+
mocha["passed"] += int(m.group(1))
|
|
213
|
+
if int(m.group(1)) == 0:
|
|
214
|
+
mocha["noTests"] = True
|
|
215
|
+
continue
|
|
216
|
+
m = _MOCHA_FAIL.match(line)
|
|
217
|
+
if m:
|
|
218
|
+
if mocha is None:
|
|
219
|
+
mocha = {"runner": "mocha", "passed": 0, "failed": 0, "noTests": False}
|
|
220
|
+
out.append(mocha)
|
|
221
|
+
mocha["failed"] += int(m.group(1))
|
|
222
|
+
continue
|
|
223
|
+
m = _RSPEC.match(line)
|
|
224
|
+
if m:
|
|
225
|
+
examples, failures = int(m.group(1)), int(m.group(2))
|
|
226
|
+
out.append({"runner": "rspec", "passed": max(0, examples - failures), "failed": failures,
|
|
227
|
+
"noTests": examples == 0})
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def decide_test_outcome(inp):
|
|
232
|
+
"""The outcome of one test run, as {"outcome": ok|failed|unknown, "why": ...}.
|
|
233
|
+
|
|
234
|
+
'inp' carries any of exitCode, isError, success and output.
|
|
235
|
+
"""
|
|
236
|
+
exit_code = inp.get("exitCode")
|
|
237
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, (int, float)) or _integer_or_none(exit_code) is None:
|
|
238
|
+
exit_code = None
|
|
239
|
+
else:
|
|
240
|
+
exit_code = int(exit_code)
|
|
241
|
+
if exit_code is not None and exit_code != 0:
|
|
242
|
+
return {"outcome": "failed", "why": "exit_code"}
|
|
243
|
+
if inp.get("isError") is True:
|
|
244
|
+
return {"outcome": "failed", "why": "is_error"}
|
|
245
|
+
if inp.get("success") is False:
|
|
246
|
+
return {"outcome": "failed", "why": "success_false"}
|
|
247
|
+
passed = 0
|
|
248
|
+
no_tests = False
|
|
249
|
+
for s in parse_test_summaries(inp.get("output")):
|
|
250
|
+
if s["failed"] > 0:
|
|
251
|
+
return {"outcome": "failed", "why": "summary_failed"}
|
|
252
|
+
passed += s["passed"]
|
|
253
|
+
if s["noTests"]:
|
|
254
|
+
no_tests = True
|
|
255
|
+
if passed == 0 and no_tests:
|
|
256
|
+
return {"outcome": "failed", "why": "no_tests"}
|
|
257
|
+
if exit_code == 0:
|
|
258
|
+
return {"outcome": "ok", "why": "exit_zero"}
|
|
259
|
+
if passed >= 1:
|
|
260
|
+
return {"outcome": "ok", "why": "summary_passed"}
|
|
261
|
+
return {"outcome": "unknown", "why": "no_signal"}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ordering_qualifier_error(side, left):
|
|
265
|
+
"""Why one side of an ordering rule cannot carry its qualifier, or None."""
|
|
266
|
+
if "[" not in side and "]" not in side:
|
|
267
|
+
return None
|
|
268
|
+
if left and side == "action:run_tests[ok]":
|
|
269
|
+
return None
|
|
270
|
+
return json.dumps(side, ensure_ascii=False) + ": [ok] qualifies only action:run_tests, on the left of ->"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def split_qualifier(token):
|
|
274
|
+
"""action:run_tests[ok] as ("action:run_tests", "ok"); any other token unqualified."""
|
|
275
|
+
return ("action:run_tests", "ok") if token == "action:run_tests[ok]" else (token, None)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def empty_outcome_state():
|
|
279
|
+
return {"v": 1, "outcomes": {}}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def outcome_state_file_name(harness, session_id, sha256_hex):
|
|
283
|
+
"""The first 32 hex of sha256(harness NUL session_id), and .json."""
|
|
284
|
+
return sha256_hex(harness + "\0" + session_id)[:32] + ".json"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def parse_outcome_state(text):
|
|
288
|
+
"""A state file's text as a state, or None when it is not one."""
|
|
289
|
+
try:
|
|
290
|
+
v = json.loads(text)
|
|
291
|
+
except ValueError:
|
|
292
|
+
return None
|
|
293
|
+
if not isinstance(v, dict) or v.get("v") != 1 or isinstance(v.get("v"), bool) or not isinstance(v.get("outcomes"), dict):
|
|
294
|
+
return None
|
|
295
|
+
outcomes = {}
|
|
296
|
+
for key, e in v["outcomes"].items():
|
|
297
|
+
if not isinstance(e, dict):
|
|
298
|
+
return None
|
|
299
|
+
at = e.get("at")
|
|
300
|
+
if e.get("o") not in ("ok", "failed", "unknown") or isinstance(at, bool) or not isinstance(at, (int, float)):
|
|
301
|
+
return None
|
|
302
|
+
if at != at or at in (float("inf"), float("-inf")):
|
|
303
|
+
return None
|
|
304
|
+
if "why" in e and not isinstance(e["why"], str):
|
|
305
|
+
return None
|
|
306
|
+
if "changed" in e and e["changed"] is not True:
|
|
307
|
+
return None
|
|
308
|
+
if "later" in e and e["later"] is not True:
|
|
309
|
+
return None
|
|
310
|
+
outcomes[key] = e
|
|
311
|
+
return {"v": 1, "outcomes": outcomes}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def record_test_outcome(state, token, decision, at_ms):
|
|
315
|
+
"""state with token's outcome replaced by this run's."""
|
|
316
|
+
outcomes = dict(state["outcomes"])
|
|
317
|
+
outcomes[token] = {"o": decision["outcome"], "at": at_ms, "why": decision["why"]}
|
|
318
|
+
return {"v": 1, "outcomes": outcomes}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def note_completed_call(state, changed_files):
|
|
322
|
+
"""state after another call completed: none adjacent any more, and stale after a file change."""
|
|
323
|
+
outcomes = {}
|
|
324
|
+
for key, e in state["outcomes"].items():
|
|
325
|
+
nxt = {"o": e["o"], "at": e["at"]}
|
|
326
|
+
if "why" in e:
|
|
327
|
+
nxt["why"] = e["why"]
|
|
328
|
+
if e.get("changed") or changed_files:
|
|
329
|
+
nxt["changed"] = True
|
|
330
|
+
nxt["later"] = True
|
|
331
|
+
outcomes[key] = nxt
|
|
332
|
+
return {"v": 1, "outcomes": outcomes}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def prerequisite_case(state, token, adjacent, now_ms):
|
|
336
|
+
"""(case, why): met only for an ok younger than a day that no file change followed
|
|
337
|
+
(and, adjacent, no other call)."""
|
|
338
|
+
e = None if state is None else state["outcomes"].get(token)
|
|
339
|
+
if e is None or not (now_ms - e["at"] < OUTCOME_STATE_TTL_MS) or e["at"] > now_ms:
|
|
340
|
+
return ("none", None)
|
|
341
|
+
if e["o"] == "failed":
|
|
342
|
+
return ("failed", e.get("why"))
|
|
343
|
+
if e["o"] == "unknown":
|
|
344
|
+
return ("unknown", e.get("why"))
|
|
345
|
+
if e.get("changed"):
|
|
346
|
+
return ("changed", None)
|
|
347
|
+
if adjacent and e.get("later"):
|
|
348
|
+
return ("not_adjacent", None)
|
|
349
|
+
return ("met", None)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def prerequisite_unmet_reason(first, then, case, why, harness):
|
|
353
|
+
"""The PREREQUISITE_UNMET reason for every case but met, byte for byte as every port words it."""
|
|
354
|
+
head = then + " needs a passing " + first + " first; "
|
|
355
|
+
if case == "none":
|
|
356
|
+
return head + "none ran in this session"
|
|
357
|
+
if case == "failed":
|
|
358
|
+
return head + "the last " + first + " failed (" + (why if why is not None else "failed") + ")"
|
|
359
|
+
if case == "unknown":
|
|
360
|
+
return head + "the result of the last " + first + " could not be read; rerun the tests so their summary is visible"
|
|
361
|
+
if case == "changed":
|
|
362
|
+
return head + "files changed after the last passing " + first + "; rerun the tests"
|
|
363
|
+
if case == "not_adjacent":
|
|
364
|
+
return head + then + " must come directly after it, and another call came between"
|
|
365
|
+
if case == "not_observable":
|
|
366
|
+
return head + harness + " reports no tool results Intutic can read, so a passing run cannot be observed here"
|
|
367
|
+
if case == "no_session":
|
|
368
|
+
return head + harness + " sent no session id, so test results cannot be tied to this session"
|
|
369
|
+
return ""
|
|
370
|
+
`;
|
|
371
|
+
//# sourceMappingURL=testOutcomePy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testOutcomePy.js","sourceRoot":"","sources":["../../src/lib/testOutcomePy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwW/C,CAAA"}
|