@intutic/sync-daemon 2.9.1 → 3.0.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 (90) hide show
  1. package/dist/disconnect/harnesses.d.ts.map +1 -1
  2. package/dist/disconnect/harnesses.js +3 -2
  3. package/dist/disconnect/harnesses.js.map +1 -1
  4. package/dist/harness/antigravityCliHooks.js +3 -3
  5. package/dist/harness/antigravityCliHooks.js.map +1 -1
  6. package/dist/harness/antigravityHooks.d.ts +8 -0
  7. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  8. package/dist/harness/antigravityHooks.js +27 -5
  9. package/dist/harness/antigravityHooks.js.map +1 -1
  10. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  11. package/dist/harness/claudeCodeHooks.js +58 -16
  12. package/dist/harness/claudeCodeHooks.js.map +1 -1
  13. package/dist/harness/clineHooks.d.ts.map +1 -1
  14. package/dist/harness/clineHooks.js +4 -2
  15. package/dist/harness/clineHooks.js.map +1 -1
  16. package/dist/harness/codexHooks.d.ts.map +1 -1
  17. package/dist/harness/codexHooks.js +4 -2
  18. package/dist/harness/codexHooks.js.map +1 -1
  19. package/dist/harness/cursorHooks.d.ts.map +1 -1
  20. package/dist/harness/cursorHooks.js +8 -3
  21. package/dist/harness/cursorHooks.js.map +1 -1
  22. package/dist/harness/gateBody.d.ts +177 -4
  23. package/dist/harness/gateBody.d.ts.map +1 -1
  24. package/dist/harness/gateBody.js +1658 -26
  25. package/dist/harness/gateBody.js.map +1 -1
  26. package/dist/harness/githubCopilotHooks.js +3 -3
  27. package/dist/harness/githubCopilotHooks.js.map +1 -1
  28. package/dist/harness/gooseHooks.d.ts.map +1 -1
  29. package/dist/harness/gooseHooks.js +8 -1
  30. package/dist/harness/gooseHooks.js.map +1 -1
  31. package/dist/harness/grokHooks.js +2 -2
  32. package/dist/harness/hermesHooks.js +1 -1
  33. package/dist/harness/hermesHooks.js.map +1 -1
  34. package/dist/harness/museHooks.d.ts.map +1 -1
  35. package/dist/harness/museHooks.js +4 -2
  36. package/dist/harness/museHooks.js.map +1 -1
  37. package/dist/harness/n8nHooks.js +1 -1
  38. package/dist/harness/openCodeHooks.d.ts.map +1 -1
  39. package/dist/harness/openCodeHooks.js +32 -8
  40. package/dist/harness/openCodeHooks.js.map +1 -1
  41. package/dist/harness/openclawHooks.d.ts.map +1 -1
  42. package/dist/harness/openclawHooks.js +19 -8
  43. package/dist/harness/openclawHooks.js.map +1 -1
  44. package/dist/harness/piHooks.js +7 -7
  45. package/dist/harness/windsurfHooks.js +2 -2
  46. package/dist/index.d.ts +9 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +8 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/lib/approvedBypasses.d.ts +17 -4
  51. package/dist/lib/approvedBypasses.d.ts.map +1 -1
  52. package/dist/lib/approvedBypasses.js +17 -4
  53. package/dist/lib/approvedBypasses.js.map +1 -1
  54. package/dist/lib/approvedPlans.d.ts +73 -0
  55. package/dist/lib/approvedPlans.d.ts.map +1 -0
  56. package/dist/lib/approvedPlans.js +124 -0
  57. package/dist/lib/approvedPlans.js.map +1 -0
  58. package/dist/lib/decideLogs.d.ts +46 -0
  59. package/dist/lib/decideLogs.d.ts.map +1 -0
  60. package/dist/lib/decideLogs.js +160 -0
  61. package/dist/lib/decideLogs.js.map +1 -0
  62. package/dist/lib/policySnapshot.d.ts +143 -3
  63. package/dist/lib/policySnapshot.d.ts.map +1 -1
  64. package/dist/lib/policySnapshot.js +480 -14
  65. package/dist/lib/policySnapshot.js.map +1 -1
  66. package/dist/lib/refusalDetailPy.d.ts +11 -0
  67. package/dist/lib/refusalDetailPy.d.ts.map +1 -0
  68. package/dist/lib/refusalDetailPy.js +109 -0
  69. package/dist/lib/refusalDetailPy.js.map +1 -0
  70. package/dist/lib/ruleRecordPy.d.ts +11 -0
  71. package/dist/lib/ruleRecordPy.d.ts.map +1 -0
  72. package/dist/lib/ruleRecordPy.js +277 -0
  73. package/dist/lib/ruleRecordPy.js.map +1 -0
  74. package/dist/lib/scopeMatchPy.d.ts +11 -0
  75. package/dist/lib/scopeMatchPy.d.ts.map +1 -0
  76. package/dist/lib/scopeMatchPy.js +278 -0
  77. package/dist/lib/scopeMatchPy.js.map +1 -0
  78. package/dist/lib/testOutcomePy.d.ts +11 -0
  79. package/dist/lib/testOutcomePy.d.ts.map +1 -0
  80. package/dist/lib/testOutcomePy.js +371 -0
  81. package/dist/lib/testOutcomePy.js.map +1 -0
  82. package/dist/skillWriter.d.ts +1 -1
  83. package/dist/skillWriter.d.ts.map +1 -1
  84. package/dist/skillWriter.js +7 -0
  85. package/dist/skillWriter.js.map +1 -1
  86. package/dist/syncCycle.d.ts +15 -2
  87. package/dist/syncCycle.d.ts.map +1 -1
  88. package/dist/syncCycle.js +15 -3
  89. package/dist/syncCycle.js.map +1 -1
  90. 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"}