@inneranimalmedia/agentsam-sdk 2.0.0 → 2.2.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.
Files changed (61) hide show
  1. package/README.md +28 -15
  2. package/docs/CAPABILITIES.md +93 -0
  3. package/docs/DEPLOY_RECEIPTS.md +83 -0
  4. package/docs/RECON.md +165 -0
  5. package/docs/RELEASES.md +4 -3
  6. package/docs/portable-knowledge.md +4 -4
  7. package/docs/sdk-2.0-release.md +19 -21
  8. package/package.json +8 -1
  9. package/packages/identity/package.json +1 -1
  10. package/packages/identity/src/frontend/auth-portal/README.md +1 -1
  11. package/packages/identity/src/index.js +2 -0
  12. package/protocol/capabilities/capability-manifest.schema.json +36 -0
  13. package/protocol/capabilities/manifest.json +179 -0
  14. package/protocol/capabilities/repository-audit-input.schema.json +14 -0
  15. package/protocol/capabilities/repository-audit.schema.json +30 -0
  16. package/protocol/capabilities/repository-snapshot-input.schema.json +11 -0
  17. package/protocol/capabilities/repository-snapshot.schema.json +20 -0
  18. package/protocol/knowledge/chunk.schema.json +15 -73
  19. package/protocol/knowledge/document.schema.json +10 -48
  20. package/protocol/knowledge/index-config.schema.json +2 -2
  21. package/protocol/knowledge/repository.schema.json +11 -52
  22. package/protocol/knowledge/retrieval-query.schema.json +13 -63
  23. package/protocol/knowledge/source.schema.json +9 -43
  24. package/protocol/presets/catalog.json +48 -0
  25. package/protocol/recon/README.md +19 -0
  26. package/protocol/recon/finding-report.schema.json +46 -0
  27. package/protocol/recon/task-packet.schema.json +79 -0
  28. package/python/agentsam_sdk/knowledge/models.py +19 -7
  29. package/python/agentsam_sdk/repository/__main__.py +2 -2
  30. package/python/agentsam_sdk/repository/recon/__init__.py +28 -0
  31. package/python/agentsam_sdk/repository/recon/__main__.py +3 -0
  32. package/python/agentsam_sdk/repository/recon/cli.py +178 -0
  33. package/python/agentsam_sdk/repository/recon/packet.py +294 -0
  34. package/python/agentsam_sdk/repository/recon/validate.py +76 -0
  35. package/python/tests/test_knowledge_models.py +6 -2
  36. package/python/tests/test_recon.py +257 -0
  37. package/src/agent/capability-adapter.js +50 -0
  38. package/src/agent/index.js +2 -0
  39. package/src/agent/repository-audit.js +188 -0
  40. package/src/capabilities/index.js +7 -0
  41. package/src/capabilities/manifest.js +22 -0
  42. package/src/capabilities/repository-snapshot.js +180 -0
  43. package/src/cli.js +68 -8
  44. package/src/commands/deploy-receipt.js +129 -0
  45. package/src/commands/deploy.js +0 -1
  46. package/src/commands/knowledge.js +5 -6
  47. package/src/commands/product.js +119 -0
  48. package/src/commands/recon.js +71 -0
  49. package/src/index.js +17 -0
  50. package/src/knowledge/config.js +12 -6
  51. package/src/knowledge/contracts.js +1 -1
  52. package/src/knowledge/engine.js +1 -1
  53. package/src/knowledge/service/server.js +2 -2
  54. package/src/lib/deploy-receipt/index.js +246 -0
  55. package/src/lib/git-context.js +3 -1
  56. package/src/presets/index.js +20 -0
  57. package/src/repository/index.js +4 -0
  58. package/test/agent-capabilities.test.mjs +67 -0
  59. package/test/capabilities.test.mjs +84 -0
  60. package/test/deploy-receipt.test.mjs +91 -0
  61. package/test/portable-context.test.mjs +11 -1
@@ -0,0 +1,294 @@
1
+ """Build a bounded ReconTaskPacket from an explicit repo root and an explicit file list.
2
+
3
+ Design law (mirrors protocol/README.md rule 6 and docs/REPOSITORY_INTELLIGENCE.md):
4
+ - Read-only. This module never writes into the target repository.
5
+ - Accepts an explicit repo_root; never assumes one repository's layout.
6
+ - Never hardcodes a tenant/workspace/provider ID as the task handle.
7
+ - The caller (a capable agent, or repository.intelligence output) selects the files.
8
+ This module does not go searching for "relevant" files on its own — that discovery
9
+ step belongs upstream, where budget and reasoning are cheap.
10
+
11
+ Stdlib only.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import subprocess
17
+ import time
18
+ import uuid
19
+ from pathlib import Path
20
+ from typing import Any, Iterable
21
+
22
+ SCHEMA_VERSION = 1
23
+ TOOL_NAME_PACK = "repository.recon.pack"
24
+ MAX_SLICES = 5
25
+ MAX_FOLLOW_UP_READS = 2
26
+ DEFAULT_TIMEOUT_SECONDS = 90
27
+ DEFAULT_MAX_OUTPUT_TOKENS = 800
28
+
29
+
30
+ class PacketError(ValueError):
31
+ """Raised when a task packet cannot be built as specified."""
32
+
33
+
34
+ def _head_sha(repo_root: Path) -> str:
35
+ try:
36
+ out = subprocess.check_output(
37
+ ["git", "rev-parse", "HEAD"], cwd=repo_root, stderr=subprocess.DEVNULL
38
+ )
39
+ return out.decode("utf-8", errors="surrogateescape").strip()
40
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
41
+ return "unknown"
42
+
43
+
44
+ def _read_slice(repo_root: Path, rel_path: str, start_line: int | None, end_line: int | None) -> str:
45
+ target = (repo_root / rel_path).resolve()
46
+ try:
47
+ target.relative_to(repo_root.resolve())
48
+ except ValueError as exc:
49
+ raise PacketError(f"slice_escapes_repo_root:{rel_path}") from exc
50
+ if not target.is_file():
51
+ raise PacketError(f"slice_not_found:{rel_path}")
52
+ try:
53
+ text = target.read_text(encoding="utf-8", errors="strict")
54
+ except (UnicodeDecodeError, OSError) as exc:
55
+ raise PacketError(f"slice_unreadable:{rel_path}") from exc
56
+ if start_line is None and end_line is None:
57
+ return text
58
+ lines = text.splitlines()
59
+ start = max(1, start_line or 1)
60
+ end = min(len(lines), end_line or len(lines))
61
+ if start > end:
62
+ raise PacketError(f"slice_range_invalid:{rel_path}:{start}-{end}")
63
+ return "\n".join(lines[start - 1 : end])
64
+
65
+
66
+ def build_task_packet(
67
+ repo_root: str | Path,
68
+ *,
69
+ question: str,
70
+ slices: Iterable[dict[str, Any]],
71
+ task_id: str | None = None,
72
+ allowed_diagnostics: Iterable[str] = (),
73
+ max_follow_up_reads: int = MAX_FOLLOW_UP_READS,
74
+ max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
75
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
76
+ embed_content: bool = True,
77
+ ) -> dict[str, Any]:
78
+ """Build one bounded ReconTaskPacket.
79
+
80
+ `slices` is a list of {"path", "start_line"?, "end_line"?, "reason"?} — exact,
81
+ caller-chosen material. This function never expands that list; it only reads and
82
+ validates it, and enforces the hard ceilings from protocol/recon/task-packet.schema.json.
83
+ """
84
+ root = Path(repo_root).expanduser().resolve()
85
+ if not root.is_dir():
86
+ raise PacketError(f"repo_root_not_found:{root}")
87
+ if not question or not question.strip():
88
+ raise PacketError("question_required")
89
+
90
+ slice_list = list(slices)
91
+ if not slice_list:
92
+ raise PacketError("at_least_one_slice_required")
93
+ if len(slice_list) > MAX_SLICES:
94
+ raise PacketError(f"too_many_slices:{len(slice_list)}>{MAX_SLICES}")
95
+ if not (0 <= max_follow_up_reads <= MAX_FOLLOW_UP_READS):
96
+ raise PacketError(f"max_follow_up_reads_out_of_range:{max_follow_up_reads}")
97
+
98
+ resolved_slices: list[dict[str, Any]] = []
99
+ for item in slice_list:
100
+ path = item.get("path")
101
+ if not path:
102
+ raise PacketError("slice_missing_path")
103
+ start_line = item.get("start_line")
104
+ end_line = item.get("end_line")
105
+ entry: dict[str, Any] = {"path": path}
106
+ if start_line is not None:
107
+ entry["start_line"] = int(start_line)
108
+ if end_line is not None:
109
+ entry["end_line"] = int(end_line)
110
+ if item.get("reason"):
111
+ entry["reason"] = str(item["reason"])
112
+ if item.get("kind"):
113
+ entry["kind"] = str(item["kind"])
114
+ if item.get("hit_count"):
115
+ entry["hit_count"] = int(item["hit_count"])
116
+ if embed_content:
117
+ entry["content"] = _read_slice(root, path, entry.get("start_line"), entry.get("end_line"))
118
+ resolved_slices.append(entry)
119
+
120
+ return {
121
+ "schema_version": SCHEMA_VERSION,
122
+ "task_id": task_id or f"recon-{uuid.uuid4().hex[:12]}",
123
+ "repo_root": str(root),
124
+ "base_sha": _head_sha(root),
125
+ "generated_at_unix": int(time.time()),
126
+ "question": question.strip(),
127
+ "slices": resolved_slices,
128
+ "allowed_diagnostics": list(allowed_diagnostics),
129
+ "ceilings": {
130
+ "max_follow_up_reads": max_follow_up_reads,
131
+ "max_output_tokens": max_output_tokens,
132
+ "timeout_seconds": timeout_seconds,
133
+ },
134
+ "response_schema_ref": "./finding-report.schema.json",
135
+ }
136
+
137
+
138
+ def from_matches(
139
+ repo_root: str | Path,
140
+ *,
141
+ question: str,
142
+ matches: Iterable[dict[str, Any]],
143
+ task_id_prefix: str = "recon",
144
+ max_files_per_packet: int = MAX_SLICES,
145
+ context_lines: int = 3,
146
+ **packet_kwargs: Any,
147
+ ) -> list[dict[str, Any]]:
148
+ """Turn raw, tool-agnostic search hits into one or more bounded packets.
149
+
150
+ This is the adapter for "I already ran rg/ast-grep and have a hit list, now I
151
+ need packets" -- it does not run any search itself. `matches` is any iterable of
152
+ {"path": str, "line": int, "kind": str?} -- output from from_ripgrep()/from_ast_grep()
153
+ (or any hand-built equivalent) works directly.
154
+
155
+ Hits are grouped by file into one slice per file (a [min-context, max+context]
156
+ line window covering every hit in that file), then chunked into packets of at
157
+ most `max_files_per_packet` files each -- so a 16-file, 41-hit sweep becomes
158
+ several small packets instead of one that violates the slice ceiling or silently
159
+ drops files.
160
+
161
+ The worker still never searches. This function is what the controller runs
162
+ *instead of* handing the worker rg.
163
+ """
164
+ if max_files_per_packet > MAX_SLICES:
165
+ raise PacketError(f"max_files_per_packet_exceeds_ceiling:{max_files_per_packet}>{MAX_SLICES}")
166
+
167
+ by_path: dict[str, dict[str, Any]] = {}
168
+ for hit in matches:
169
+ path = hit.get("path")
170
+ if not path:
171
+ raise PacketError("match_missing_path")
172
+ line = hit.get("line")
173
+ entry = by_path.setdefault(path, {"lines": [], "kinds": set()})
174
+ if line is not None:
175
+ entry["lines"].append(int(line))
176
+ if hit.get("kind"):
177
+ entry["kinds"].add(str(hit["kind"]))
178
+
179
+ if not by_path:
180
+ raise PacketError("no_matches_supplied")
181
+
182
+ files = sorted(by_path)
183
+ packets: list[dict[str, Any]] = []
184
+ chunks = [files[i : i + max_files_per_packet] for i in range(0, len(files), max_files_per_packet)]
185
+ for idx, chunk in enumerate(chunks, start=1):
186
+ slices = []
187
+ for path in chunk:
188
+ entry = by_path[path]
189
+ lines = entry["lines"]
190
+ if lines:
191
+ start = max(1, min(lines) - context_lines)
192
+ end = max(lines) + context_lines
193
+ else:
194
+ start = end = None
195
+ kinds = sorted(entry["kinds"])
196
+ reason = f"{len(lines) or 'unknown-count of'} hit(s)"
197
+ if kinds:
198
+ reason += f" ({', '.join(kinds)})"
199
+ slice_entry: dict[str, Any] = {"path": path, "reason": reason}
200
+ if start is not None:
201
+ slice_entry["start_line"] = start
202
+ slice_entry["end_line"] = end
203
+ if lines:
204
+ slice_entry["hit_count"] = len(lines)
205
+ if len(kinds) == 1:
206
+ slice_entry["kind"] = kinds[0]
207
+ slices.append(slice_entry)
208
+ suffix = f"-{idx}of{len(chunks)}" if len(chunks) > 1 else ""
209
+ packets.append(
210
+ build_task_packet(
211
+ repo_root,
212
+ question=question,
213
+ slices=slices,
214
+ task_id=f"{task_id_prefix}{suffix}",
215
+ **packet_kwargs,
216
+ )
217
+ )
218
+ return packets
219
+
220
+
221
+ def from_ripgrep(rg_json: str | Iterable[str]) -> list[dict[str, Any]]:
222
+ """Parse `rg --json` NDJSON output into `from_matches()`-shaped hits.
223
+
224
+ Runs no search itself -- pass the captured stdout of an `rg --json ...` call
225
+ (a single string, or an iterable of its lines). Only `type: "match"` records are
226
+ kept. rg is lexical only, so hits carry no `kind` -- pair with `from_ast_grep()`
227
+ output in the same `matches` list when structural classification is available.
228
+
229
+ Example::
230
+
231
+ raw = subprocess.run(
232
+ ["rg", "--json", "-e", "workspace_id", "backend/workflows"],
233
+ capture_output=True, text=True,
234
+ ).stdout
235
+ matches = recon.from_ripgrep(raw)
236
+ """
237
+ lines = rg_json.splitlines() if isinstance(rg_json, str) else rg_json
238
+ hits: list[dict[str, Any]] = []
239
+ for line in lines:
240
+ line = line.strip()
241
+ if not line:
242
+ continue
243
+ try:
244
+ obj = json.loads(line)
245
+ except json.JSONDecodeError as exc:
246
+ raise PacketError(f"rg_json_line_invalid:{line[:80]}") from exc
247
+ if obj.get("type") != "match":
248
+ continue
249
+ data = obj.get("data", {})
250
+ path = (data.get("path") or {}).get("text")
251
+ line_number = data.get("line_number")
252
+ if not path or line_number is None:
253
+ continue
254
+ hits.append({"path": path, "line": int(line_number)})
255
+ return hits
256
+
257
+
258
+ def from_ast_grep(sg_json_compact: str, *, kind: str | None = None) -> list[dict[str, Any]]:
259
+ """Parse `sg`/`ast-grep ... --json=compact` array output into `from_matches()`-shaped hits.
260
+
261
+ Runs no search itself. One ast-grep invocation is one pattern or one inline rule --
262
+ i.e. one structural shape -- so `kind` is a caller-supplied label applied to every
263
+ hit from that call (e.g. "member", "member_camel", "sql_string"), not something
264
+ this function infers. Redirect the deprecation banner ast-grep prints on stderr
265
+ away from stdout (it does not appear in --json=compact stdout, but callers piping
266
+ `2>&1` will see it mixed in).
267
+
268
+ Example::
269
+
270
+ raw = subprocess.run(
271
+ ["sg", "-p", "$X.workspace_id", "-l", "js", "--json=compact",
272
+ "backend/workflows"],
273
+ capture_output=True, text=True,
274
+ ).stdout
275
+ matches = recon.from_ast_grep(raw, kind="member")
276
+ """
277
+ text = sg_json_compact.strip()
278
+ if not text:
279
+ return []
280
+ try:
281
+ records = json.loads(text)
282
+ except json.JSONDecodeError as exc:
283
+ raise PacketError(f"sg_json_invalid:{text[:80]}") from exc
284
+ hits: list[dict[str, Any]] = []
285
+ for record in records:
286
+ path = record.get("file")
287
+ line_number = ((record.get("range") or {}).get("start") or {}).get("line")
288
+ if not path or line_number is None:
289
+ continue
290
+ hit: dict[str, Any] = {"path": path, "line": int(line_number)}
291
+ if kind:
292
+ hit["kind"] = kind
293
+ hits.append(hit)
294
+ return hits
@@ -0,0 +1,76 @@
1
+ """Validate a worker's ReconFindingReport against its originating packet.
2
+
3
+ This is the deterministic validator in the pipeline:
4
+ controller -> packet -> mini worker -> [this module] -> capable agent
5
+
6
+ It does not trust the worker. It checks structure, checks that every referenced file
7
+ was actually inside the packet's slices (a worker cannot invent evidence for a file it
8
+ was never given), and enforces the "never guess" rule: a report that isn't clearly
9
+ `answered` or `needs_context` is rejected rather than passed upstream.
10
+
11
+ Stdlib only.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ TOOL_NAME_VALIDATE = "repository.recon.validate"
18
+ REQUIRED_TOP_LEVEL = ("schema_version", "task_id", "status")
19
+ VALID_STATUSES = ("answered", "needs_context")
20
+ VALID_SEVERITIES = ("low", "medium", "high")
21
+
22
+
23
+ class ReportError(ValueError):
24
+ """Raised when a finding report fails validation and must not be forwarded."""
25
+
26
+
27
+ def _known_paths(packet: dict[str, Any]) -> set[str]:
28
+ return {s["path"] for s in packet.get("slices", [])}
29
+
30
+
31
+ def validate_report(report: dict[str, Any], packet: dict[str, Any]) -> dict[str, Any]:
32
+ """Return the report unchanged if valid; raise ReportError otherwise.
33
+
34
+ Callers should treat a ReportError as "discard this worker output, do not hand it
35
+ to the capable agent" — never as something to silently patch and pass along.
36
+ """
37
+ if not isinstance(report, dict):
38
+ raise ReportError("report_not_an_object")
39
+
40
+ missing = [key for key in REQUIRED_TOP_LEVEL if key not in report]
41
+ if missing:
42
+ raise ReportError(f"missing_fields:{','.join(missing)}")
43
+
44
+ if report.get("schema_version") != 1:
45
+ raise ReportError(f"unsupported_schema_version:{report.get('schema_version')}")
46
+
47
+ if report.get("task_id") != packet.get("task_id"):
48
+ raise ReportError("task_id_mismatch")
49
+
50
+ status = report.get("status")
51
+ if status not in VALID_STATUSES:
52
+ raise ReportError(f"invalid_status:{status}")
53
+
54
+ if status == "needs_context":
55
+ if not report.get("reason"):
56
+ raise ReportError("needs_context_missing_reason")
57
+ return report
58
+
59
+ # status == "answered"
60
+ known = _known_paths(packet)
61
+ for finding in report.get("findings", []):
62
+ for key in ("severity", "file", "finding"):
63
+ if key not in finding:
64
+ raise ReportError(f"finding_missing_field:{key}")
65
+ if finding["severity"] not in VALID_SEVERITIES:
66
+ raise ReportError(f"invalid_severity:{finding['severity']}")
67
+ if finding["file"] not in known:
68
+ raise ReportError(
69
+ f"finding_cites_file_outside_packet:{finding['file']}"
70
+ )
71
+
72
+ for path in report.get("affected_files", []):
73
+ if path not in known:
74
+ raise ReportError(f"affected_file_outside_packet:{path}")
75
+
76
+ return report
@@ -12,10 +12,14 @@ class KnowledgeContractsTest(unittest.TestCase):
12
12
  return {"run_id": "run", "source_id": payload["source_id"], "status": "succeeded"}
13
13
  return {"query_id": "query", "hits": [{"chunk_id": "chunk", "content": "code", "score": 1, "lane": "code"}]}
14
14
  client = KnowledgeClient(Transport())
15
- query = RetrievalQuery(text="function", workspace_id="workspace")
15
+ query = RetrievalQuery(text="function")
16
16
  self.assertEqual(asyncio.run(client.retrieve(query)).hits[0].content, "code")
17
17
  self.assertEqual(asyncio.run(client.index("source")).status, "succeeded")
18
- self.assertEqual(Source("s", "repository", "workspace", "file:///repo").source_id, "s")
18
+ self.assertEqual(Source("s", "repository", "file:///repo").source_id, "s")
19
+
20
+ def test_workspace_is_optional_compatibility_metadata(self):
21
+ query = RetrievalQuery(text="function", workspace_id="legacy-workspace")
22
+ self.assertEqual(query.workspace_id, "legacy-workspace")
19
23
 
20
24
 
21
25
  if __name__ == "__main__":
@@ -0,0 +1,257 @@
1
+ """Unit tests for repository.recon -- packet building and report validation.
2
+
3
+ No git dependency required (base_sha falls back to "unknown" outside a repo);
4
+ no model calls; no network.
5
+ """
6
+ import json
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ from agentsam_sdk.repository.recon import (
12
+ PacketError,
13
+ ReportError,
14
+ build_task_packet,
15
+ from_ast_grep,
16
+ from_matches,
17
+ from_ripgrep,
18
+ validate_report,
19
+ )
20
+
21
+
22
+ class TestBuildTaskPacket(unittest.TestCase):
23
+ def setUp(self):
24
+ self.tmp = tempfile.TemporaryDirectory()
25
+ self.root = Path(self.tmp.name)
26
+ (self.root / "backend").mkdir()
27
+ (self.root / "backend" / "workflows.js").write_text(
28
+ "\n".join(f"line {i}" for i in range(1, 201)), encoding="utf-8"
29
+ )
30
+
31
+ def tearDown(self):
32
+ self.tmp.cleanup()
33
+
34
+ def test_builds_bounded_packet_with_content(self):
35
+ packet = build_task_packet(
36
+ self.root,
37
+ question="Does this file depend on workspace_id?",
38
+ slices=[{"path": "backend/workflows.js", "start_line": 1, "end_line": 5}],
39
+ )
40
+ self.assertEqual(packet["schema_version"], 1)
41
+ self.assertEqual(len(packet["slices"]), 1)
42
+ self.assertEqual(packet["slices"][0]["content"], "line 1\nline 2\nline 3\nline 4\nline 5")
43
+ self.assertEqual(packet["ceilings"]["max_follow_up_reads"], 2)
44
+ self.assertTrue(packet["task_id"].startswith("recon-"))
45
+
46
+ def test_rejects_missing_question(self):
47
+ with self.assertRaises(PacketError):
48
+ build_task_packet(self.root, question=" ", slices=[{"path": "backend/workflows.js"}])
49
+
50
+ def test_rejects_more_than_five_slices(self):
51
+ slices = [{"path": "backend/workflows.js"} for _ in range(6)]
52
+ with self.assertRaises(PacketError):
53
+ build_task_packet(self.root, question="q", slices=slices)
54
+
55
+ def test_rejects_slice_outside_repo_root(self):
56
+ with self.assertRaises(PacketError):
57
+ build_task_packet(self.root, question="q", slices=[{"path": "../../etc/passwd"}])
58
+
59
+ def test_rejects_missing_file(self):
60
+ with self.assertRaises(PacketError):
61
+ build_task_packet(self.root, question="q", slices=[{"path": "backend/nope.js"}])
62
+
63
+
64
+ class TestFromMatches(unittest.TestCase):
65
+ def setUp(self):
66
+ self.tmp = tempfile.TemporaryDirectory()
67
+ self.root = Path(self.tmp.name)
68
+ (self.root / "backend").mkdir()
69
+ for name in ("a.js", "b.js", "c.js", "d.js", "e.js", "f.js"):
70
+ (self.root / "backend" / name).write_text(
71
+ "\n".join(f"line {i}" for i in range(1, 51)), encoding="utf-8"
72
+ )
73
+
74
+ def tearDown(self):
75
+ self.tmp.cleanup()
76
+
77
+ def test_chunks_more_than_five_files_into_multiple_packets(self):
78
+ # 6 files -> ceiling is 5 per packet -> 2 packets
79
+ matches = [
80
+ {"path": f"backend/{name}", "line": 10, "kind": "member"}
81
+ for name in ("a.js", "b.js", "c.js", "d.js", "e.js", "f.js")
82
+ ]
83
+ packets = from_matches(self.root, question="q", matches=matches, task_id_prefix="wf")
84
+ self.assertEqual(len(packets), 2)
85
+ self.assertEqual(packets[0]["task_id"], "wf-1of2")
86
+ self.assertEqual(packets[1]["task_id"], "wf-2of2")
87
+ total_slices = sum(len(p["slices"]) for p in packets)
88
+ self.assertEqual(total_slices, 6)
89
+ for p in packets:
90
+ self.assertLessEqual(len(p["slices"]), 5)
91
+
92
+ def test_groups_multiple_hits_in_one_file_into_one_windowed_slice(self):
93
+ matches = [
94
+ {"path": "backend/a.js", "line": 5, "kind": "sql_string"},
95
+ {"path": "backend/a.js", "line": 20, "kind": "sql_string"},
96
+ ]
97
+ packets = from_matches(self.root, question="q", matches=matches, context_lines=2)
98
+ slice_ = packets[0]["slices"][0]
99
+ self.assertEqual(slice_["start_line"], 3)
100
+ self.assertEqual(slice_["end_line"], 22)
101
+ self.assertEqual(slice_["hit_count"], 2)
102
+ self.assertEqual(slice_["kind"], "sql_string")
103
+
104
+ def test_rejects_empty_matches(self):
105
+ with self.assertRaises(PacketError):
106
+ from_matches(self.root, question="q", matches=[])
107
+
108
+
109
+ class TestFromRipgrep(unittest.TestCase):
110
+ def test_parses_ndjson_match_lines_only(self):
111
+ # Real `rg --json` shape: begin/match/end/summary records per file.
112
+ ndjson = "\n".join([
113
+ json.dumps({"type": "begin", "data": {"path": {"text": "backend/http/workflows/scope.js"}}}),
114
+ json.dumps({
115
+ "type": "match",
116
+ "data": {
117
+ "path": {"text": "backend/http/workflows/scope.js"},
118
+ "line_number": 66,
119
+ "lines": {"text": " body.workspace_id\n"},
120
+ },
121
+ }),
122
+ json.dumps({"type": "end", "data": {"path": {"text": "backend/http/workflows/scope.js"}}}),
123
+ json.dumps({"type": "summary", "data": {}}),
124
+ ])
125
+ hits = from_ripgrep(ndjson)
126
+ self.assertEqual(hits, [{"path": "backend/http/workflows/scope.js", "line": 66}])
127
+
128
+ def test_accepts_a_list_of_lines_too(self):
129
+ lines = [json.dumps({"type": "match", "data": {"path": {"text": "a.js"}, "line_number": 1}})]
130
+ self.assertEqual(from_ripgrep(lines), [{"path": "a.js", "line": 1}])
131
+
132
+ def test_empty_input_yields_no_hits(self):
133
+ self.assertEqual(from_ripgrep(""), [])
134
+
135
+ def test_rejects_malformed_json_line(self):
136
+ with self.assertRaises(PacketError):
137
+ from_ripgrep("not json")
138
+
139
+
140
+ class TestFromAstGrep(unittest.TestCase):
141
+ def test_parses_compact_json_array_with_caller_supplied_kind(self):
142
+ # Real `sg -p '...' --json=compact` shape.
143
+ raw = json.dumps([
144
+ {
145
+ "text": "body.workspace_id",
146
+ "range": {"start": {"line": 66, "column": 76}, "end": {"line": 66, "column": 93}},
147
+ "file": "backend/http/workflows/scope.js",
148
+ },
149
+ {
150
+ "text": "ctx.workspaceId",
151
+ "range": {"start": {"line": 12, "column": 4}, "end": {"line": 12, "column": 19}},
152
+ "file": "backend/workflows/handlers/tool.js",
153
+ },
154
+ ])
155
+ hits = from_ast_grep(raw, kind="member")
156
+ self.assertEqual(hits, [
157
+ {"path": "backend/http/workflows/scope.js", "line": 66, "kind": "member"},
158
+ {"path": "backend/workflows/handlers/tool.js", "line": 12, "kind": "member"},
159
+ ])
160
+
161
+ def test_kind_is_optional(self):
162
+ raw = json.dumps([{"range": {"start": {"line": 1}}, "file": "a.js"}])
163
+ self.assertEqual(from_ast_grep(raw), [{"path": "a.js", "line": 1}])
164
+
165
+ def test_empty_array_yields_no_hits(self):
166
+ self.assertEqual(from_ast_grep("[]"), [])
167
+
168
+ def test_rejects_invalid_json(self):
169
+ with self.assertRaises(PacketError):
170
+ from_ast_grep("not json")
171
+
172
+ def test_adapters_compose_into_from_matches(self):
173
+ # The actual workflow: run several classified sg queries + one rg sweep,
174
+ # concatenate, then chunk into packets -- same shape as the iMac smoke test.
175
+ member_hits = from_ast_grep(
176
+ json.dumps([{"range": {"start": {"line": 5}}, "file": "backend/a.js"}]), kind="member"
177
+ )
178
+ sql_hits = from_ast_grep(
179
+ json.dumps([{"range": {"start": {"line": 20}}, "file": "backend/b.js"}]), kind="sql_string"
180
+ )
181
+ rg_hits = from_ripgrep(json.dumps({
182
+ "type": "match", "data": {"path": {"text": "backend/c.js"}, "line_number": 8},
183
+ }))
184
+ combined = member_hits + sql_hits + rg_hits
185
+ self.assertEqual(len(combined), 3)
186
+ self.assertEqual({h["path"] for h in combined}, {"backend/a.js", "backend/b.js", "backend/c.js"})
187
+ self.assertEqual(combined[2].get("kind"), None) # rg hit carries no kind
188
+
189
+
190
+ class TestValidateReport(unittest.TestCase):
191
+ def setUp(self):
192
+ self.packet = {
193
+ "task_id": "recon-abc123",
194
+ "slices": [{"path": "backend/workflows.js"}],
195
+ }
196
+
197
+ def test_accepts_answered_report_citing_known_file(self):
198
+ report = {
199
+ "schema_version": 1,
200
+ "task_id": "recon-abc123",
201
+ "status": "answered",
202
+ "findings": [
203
+ {
204
+ "severity": "high",
205
+ "file": "backend/workflows.js",
206
+ "finding": "still queries workspace_id",
207
+ }
208
+ ],
209
+ }
210
+ self.assertEqual(validate_report(report, self.packet), report)
211
+
212
+ def test_accepts_needs_context_with_reason(self):
213
+ report = {
214
+ "schema_version": 1,
215
+ "task_id": "recon-abc123",
216
+ "status": "needs_context",
217
+ "reason": "definition of ensureWorkflowRun() not supplied",
218
+ }
219
+ self.assertEqual(validate_report(report, self.packet)["status"], "needs_context")
220
+
221
+ def test_rejects_needs_context_without_reason(self):
222
+ report = {"schema_version": 1, "task_id": "recon-abc123", "status": "needs_context"}
223
+ with self.assertRaises(ReportError):
224
+ validate_report(report, self.packet)
225
+
226
+ def test_rejects_finding_that_cites_a_file_outside_the_packet(self):
227
+ report = {
228
+ "schema_version": 1,
229
+ "task_id": "recon-abc123",
230
+ "status": "answered",
231
+ "findings": [
232
+ {"severity": "high", "file": "backend/other.js", "finding": "made up"}
233
+ ],
234
+ }
235
+ with self.assertRaises(ReportError):
236
+ validate_report(report, self.packet)
237
+
238
+ def test_rejects_task_id_mismatch(self):
239
+ report = {"schema_version": 1, "task_id": "recon-different", "status": "needs_context", "reason": "x"}
240
+ with self.assertRaises(ReportError):
241
+ validate_report(report, self.packet)
242
+
243
+ def test_rejects_invalid_severity(self):
244
+ report = {
245
+ "schema_version": 1,
246
+ "task_id": "recon-abc123",
247
+ "status": "answered",
248
+ "findings": [
249
+ {"severity": "critical", "file": "backend/workflows.js", "finding": "x"}
250
+ ],
251
+ }
252
+ with self.assertRaises(ReportError):
253
+ validate_report(report, self.packet)
254
+
255
+
256
+ if __name__ == "__main__":
257
+ unittest.main()