squackit 0.3.3__tar.gz → 0.4.0__tar.gz

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 (52) hide show
  1. {squackit-0.3.3 → squackit-0.4.0}/PKG-INFO +2 -2
  2. {squackit-0.3.3 → squackit-0.4.0}/pyproject.toml +2 -2
  3. {squackit-0.3.3 → squackit-0.4.0}/squackit/__init__.py +1 -1
  4. {squackit-0.3.3 → squackit-0.4.0}/squackit/cli.py +35 -5
  5. {squackit-0.3.3 → squackit-0.4.0}/squackit/server.py +9 -3
  6. {squackit-0.3.3 → squackit-0.4.0}/squackit/tools.py +65 -5
  7. {squackit-0.3.3 → squackit-0.4.0}/tests/test_cli_tools.py +17 -0
  8. {squackit-0.3.3 → squackit-0.4.0}/tests/test_smoke.py +1 -1
  9. squackit-0.4.0/tests/test_tools.py +282 -0
  10. squackit-0.3.3/tests/test_tools.py +0 -142
  11. {squackit-0.3.3 → squackit-0.4.0}/.github/workflows/release.yml +0 -0
  12. {squackit-0.3.3 → squackit-0.4.0}/.gitignore +0 -0
  13. {squackit-0.3.3 → squackit-0.4.0}/.mcp.json +0 -0
  14. {squackit-0.3.3 → squackit-0.4.0}/.readthedocs.yaml +0 -0
  15. {squackit-0.3.3 → squackit-0.4.0}/.readthedocs.yml +0 -0
  16. {squackit-0.3.3 → squackit-0.4.0}/CLAUDE.md +0 -0
  17. {squackit-0.3.3 → squackit-0.4.0}/LICENSE +0 -0
  18. {squackit-0.3.3 → squackit-0.4.0}/README.md +0 -0
  19. {squackit-0.3.3 → squackit-0.4.0}/docs/architecture.md +0 -0
  20. {squackit-0.3.3 → squackit-0.4.0}/docs/configuration.md +0 -0
  21. {squackit-0.3.3 → squackit-0.4.0}/docs/index.md +0 -0
  22. {squackit-0.3.3 → squackit-0.4.0}/docs/prompts.md +0 -0
  23. {squackit-0.3.3 → squackit-0.4.0}/docs/quickstart.md +0 -0
  24. {squackit-0.3.3 → squackit-0.4.0}/docs/resources.md +0 -0
  25. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-10-phase1-handoff.md +0 -0
  26. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-10-squawkit-extraction.md +0 -0
  27. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md +0 -0
  28. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-12-cli-tool-exposure.md +0 -0
  29. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md +0 -0
  30. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-12-session-handoff.md +0 -0
  31. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/specs/2026-04-10-squawkit-design.md +0 -0
  32. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md +0 -0
  33. {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/specs/2026-04-12-pluckit-tool-integration-design.md +0 -0
  34. {squackit-0.3.3 → squackit-0.4.0}/docs/tools.md +0 -0
  35. {squackit-0.3.3 → squackit-0.4.0}/mkdocs.yml +0 -0
  36. {squackit-0.3.3 → squackit-0.4.0}/squackit/__main__.py +0 -0
  37. {squackit-0.3.3 → squackit-0.4.0}/squackit/db.py +0 -0
  38. {squackit-0.3.3 → squackit-0.4.0}/squackit/defaults.py +0 -0
  39. {squackit-0.3.3 → squackit-0.4.0}/squackit/formatting.py +0 -0
  40. {squackit-0.3.3 → squackit-0.4.0}/squackit/prompts.py +0 -0
  41. {squackit-0.3.3 → squackit-0.4.0}/squackit/session.py +0 -0
  42. {squackit-0.3.3 → squackit-0.4.0}/squackit/tool_config.py +0 -0
  43. {squackit-0.3.3 → squackit-0.4.0}/squackit/workflows.py +0 -0
  44. {squackit-0.3.3 → squackit-0.4.0}/tests/conftest.py +0 -0
  45. {squackit-0.3.3 → squackit-0.4.0}/tests/test_defaults.py +0 -0
  46. {squackit-0.3.3 → squackit-0.4.0}/tests/test_formatting_json.py +0 -0
  47. {squackit-0.3.3 → squackit-0.4.0}/tests/test_prompts.py +0 -0
  48. {squackit-0.3.3 → squackit-0.4.0}/tests/test_resources.py +0 -0
  49. {squackit-0.3.3 → squackit-0.4.0}/tests/test_session.py +0 -0
  50. {squackit-0.3.3 → squackit-0.4.0}/tests/test_tool_config.py +0 -0
  51. {squackit-0.3.3 → squackit-0.4.0}/tests/test_truncation.py +0 -0
  52. {squackit-0.3.3 → squackit-0.4.0}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: squackit
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents.
5
5
  Project-URL: Homepage, https://github.com/teaguesterling/squackit
6
6
  Project-URL: Documentation, https://squackit.readthedocs.io
@@ -16,7 +16,7 @@ Classifier: License :: OSI Approved :: Apache Software License
16
16
  Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Requires-Python: >=3.9
19
- Requires-Dist: ast-pluckit>=0.7.0
19
+ Requires-Dist: ast-pluckit>=0.9.0
20
20
  Requires-Dist: click>=8.0
21
21
  Requires-Dist: fastmcp>=3.0
22
22
  Provides-Extra: docs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "squackit"
3
- version = "0.3.3"
3
+ version = "0.4.0"
4
4
  description = "Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "License :: OSI Approved :: Apache Software License",
18
18
  ]
19
19
  dependencies = [
20
- "ast-pluckit>=0.7.0",
20
+ "ast-pluckit>=0.9.0",
21
21
  "click>=8.0",
22
22
  "fastmcp>=3.0",
23
23
  ]
@@ -1,3 +1,3 @@
1
1
  """squackit: Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.4.0"
@@ -95,10 +95,12 @@ class ToolGroup(click.Group):
95
95
  def _get_registry():
96
96
  """Lazily build the tool registry."""
97
97
  from pluckit import Plucker
98
- from squackit.tools import PLUCKIT_TOOLS
99
- p = Plucker()
98
+ from pluckit.pluckins.viewer import AstViewer
99
+ from squackit.tools import PLUCKIT_TOOLS, collect_pluckin_tools
100
+ p = Plucker(plugins=[AstViewer])
100
101
  con = p.connection
101
- return build_tool_registry(con._tools, extra_tools=PLUCKIT_TOOLS), con
102
+ extra = list(PLUCKIT_TOOLS) + collect_pluckin_tools(p)
103
+ return build_tool_registry(con._tools, extra_tools=extra), con
102
104
 
103
105
 
104
106
  def _format_result(result, presentation, json_output):
@@ -286,18 +288,29 @@ def pluck(click_ctx, argv):
286
288
  squackit pluck "src/api.py" find .fn#handler view
287
289
  squackit pluck "**/*.py" find .fn names -- find .class names
288
290
 
291
+ Mutations (rename, replaceWith, wrap, etc.) are blocked by default.
292
+ Pass --write to allow them. Example:
293
+
294
+ squackit pluck --write "src/api.py" find .fn#old rename new
295
+
289
296
  See pluckit documentation for full chain grammar.
290
297
  """
291
298
  from pluckit import Chain
292
299
  from squackit.formatting import _format_markdown_table, format_json
300
+ from squackit.tools import _chain_mutation_ops
293
301
 
294
302
  if not argv:
295
303
  click.echo(click_ctx.get_help())
296
304
  return
297
305
 
306
+ # Extract squackit-level --write flag before passing to pluckit
307
+ argv_list = list(argv)
308
+ write_mode = "--write" in argv_list
309
+ if write_mode:
310
+ argv_list = [a for a in argv_list if a != "--write"]
311
+
298
312
  try:
299
- chain = Chain.from_argv(list(argv))
300
- result = chain.evaluate()
313
+ chain = Chain.from_argv(argv_list)
301
314
  except SystemExit:
302
315
  raise
303
316
  except Exception as e:
@@ -305,6 +318,23 @@ def pluck(click_ctx, argv):
305
318
  click_ctx.exit(1)
306
319
  return
307
320
 
321
+ mutations = _chain_mutation_ops(chain)
322
+ if mutations and not write_mode:
323
+ click.echo(
324
+ f"Error: chain contains mutation operations: {', '.join(mutations)}\n"
325
+ f"Pass --write to allow mutations (they modify source files).",
326
+ err=True,
327
+ )
328
+ click_ctx.exit(1)
329
+ return
330
+
331
+ try:
332
+ result = chain.evaluate()
333
+ except Exception as e:
334
+ click.echo(f"Error: {e}", err=True)
335
+ click_ctx.exit(1)
336
+ return
337
+
308
338
  json_output = click_ctx.obj.get("json", False) if click_ctx.obj else False
309
339
  data = result.get("data")
310
340
  result_type = result.get("type")
@@ -62,8 +62,13 @@ def create_server(
62
62
  A FastMCP server instance ready to .run().
63
63
  """
64
64
  from fastmcp import FastMCP
65
+ from pluckit.pluckins.viewer import AstViewer
65
66
 
66
- con = Plucker(repo=root, profile=profile, modules=modules, init=init).connection
67
+ plucker = Plucker(
68
+ repo=root, profile=profile, modules=modules, init=init,
69
+ plugins=[AstViewer],
70
+ )
71
+ con = plucker.connection
67
72
  mcp = FastMCP(name)
68
73
 
69
74
  # Infer smart defaults, merge with config file overrides
@@ -78,8 +83,9 @@ def create_server(
78
83
  mcp.access_log = access_log
79
84
 
80
85
  # Register each macro as an MCP tool
81
- from squackit.tools import PLUCKIT_TOOLS
82
- registry = build_tool_registry(con._tools, extra_tools=PLUCKIT_TOOLS)
86
+ from squackit.tools import PLUCKIT_TOOLS, collect_pluckin_tools
87
+ extra = list(PLUCKIT_TOOLS) + collect_pluckin_tools(plucker)
88
+ registry = build_tool_registry(con._tools, extra_tools=extra)
83
89
  for presentation in registry.values():
84
90
  _register_tool(mcp, con, presentation, defaults, cache, access_log)
85
91
 
@@ -15,7 +15,7 @@ from squackit.tool_config import ToolPresentation
15
15
  def _make_plucker(source: str):
16
16
  """Create a Plucker with AstViewer for tool execution."""
17
17
  from pluckit import Plucker
18
- from pluckit.plugins.viewer import AstViewer
18
+ from pluckit.pluckins.viewer import AstViewer
19
19
  return Plucker(code=source, plugins=[AstViewer])
20
20
 
21
21
 
@@ -54,18 +54,60 @@ def complexity_executor(*, source: str, selector: str):
54
54
  return rel
55
55
 
56
56
 
57
- def pluck_executor(*, argv: str):
57
+ def _chain_mutation_ops(chain) -> list[str]:
58
+ """Return the list of mutation op names present in the chain (in order)."""
59
+ from pluckit.chain import Chain as _Chain
60
+ return [step.op for step in chain.steps if step.op in _Chain._MUTATION_OPS]
61
+
62
+
63
+ def collect_pluckin_tools(plucker) -> list:
64
+ """Collect squackit tools from a Plucker's registered pluckins.
65
+
66
+ Pluckins that want to contribute squackit tools expose a
67
+ ``squackit_tools()`` method returning a list of ToolPresentation
68
+ objects. This function walks the Plucker's pluckin registry and
69
+ collects tools from any pluckin that implements the method.
70
+
71
+ Plugin authors can add squackit integration without depending on
72
+ squackit at import time — the import lives inside the method body
73
+ and only fires when squackit actually calls it.
74
+ """
75
+ tools: list = []
76
+ registry = getattr(plucker, "_registry", None)
77
+ if registry is None:
78
+ return tools
79
+ pluckins = getattr(registry, "pluckins", None)
80
+ if pluckins is None:
81
+ return tools
82
+ for pluckin in pluckins:
83
+ fn = getattr(pluckin, "squackit_tools", None)
84
+ if callable(fn):
85
+ try:
86
+ tools.extend(fn())
87
+ except Exception:
88
+ # A broken pluckin shouldn't break the whole registry.
89
+ # Squackit's server/CLI will surface the error contextually.
90
+ pass
91
+ return tools
92
+
93
+
94
+ def pluck_executor(*, argv: str, allow_mutations: str | bool = False):
58
95
  """Execute a pluckit chain from a whitespace-separated command string.
59
96
 
60
97
  Accepts the same grammar as `squackit pluck` on the CLI:
61
98
  "source_pattern [method [arg]]... [terminal]"
62
99
 
100
+ **Mutation safety:** chains containing mutation operations (rename,
101
+ replaceWith, wrap, remove, etc.) are blocked by default. Pass
102
+ ``allow_mutations=true`` to opt in. Agents should only enable this
103
+ when the user has explicitly authorized code changes.
104
+
63
105
  Returns a JSON-serialized chain result: {chain, type, data}.
64
106
 
65
107
  Examples:
66
108
  pluck(argv="**/*.py find .fn names")
67
109
  pluck(argv="--plugin AstViewer src/api.py find .fn#handler view")
68
- pluck(argv="**/*.py find .fn names reset find .class names")
110
+ pluck(argv="**/*.py find .fn#old rename new_name", allow_mutations=True)
69
111
  """
70
112
  import shlex
71
113
  import json
@@ -76,6 +118,23 @@ def pluck_executor(*, argv: str):
76
118
  return json.dumps({"error": "Empty argv"}, indent=2)
77
119
 
78
120
  chain = Chain.from_argv(tokens)
121
+
122
+ # Coerce string "true"/"false" from MCP clients to bool
123
+ if isinstance(allow_mutations, str):
124
+ allow = allow_mutations.lower() in ("true", "1", "yes")
125
+ else:
126
+ allow = bool(allow_mutations)
127
+
128
+ mutations = _chain_mutation_ops(chain)
129
+ if mutations and not allow:
130
+ return json.dumps({
131
+ "error": "blocked: chain contains mutation operations",
132
+ "mutations": mutations,
133
+ "hint": "Set allow_mutations=true to enable. Mutations modify "
134
+ "source files — ensure the user has authorized changes.",
135
+ "chain": chain.to_dict(),
136
+ }, indent=2)
137
+
79
138
  result = chain.evaluate()
80
139
  return json.dumps(result, indent=2, default=str)
81
140
 
@@ -128,7 +187,7 @@ COMPLEXITY_TOOL = ToolPresentation(
128
187
  PLUCK_TOOL = ToolPresentation(
129
188
  info=ToolInfo(
130
189
  macro_name="pluck",
131
- params=["argv"],
190
+ params=["argv", "allow_mutations"],
132
191
  description=(
133
192
  "Execute a pluckit chain query. Pass a whitespace-separated "
134
193
  "command: 'source_pattern [method [arg]]... [terminal]'. "
@@ -136,7 +195,8 @@ PLUCK_TOOL = ToolPresentation(
136
195
  "Use 'reset' to start a new chain from the source. "
137
196
  "Example: '**/*.py find .fn containing cache names'. "
138
197
  "Use '--plugin AstViewer' prefix for view terminals. "
139
- "Returns JSON: {chain, type, data}."
198
+ "Mutations (rename, replaceWith, wrap, etc.) are blocked "
199
+ "unless allow_mutations=true. Returns JSON: {chain, type, data}."
140
200
  ),
141
201
  required=["argv"],
142
202
  ),
@@ -139,3 +139,20 @@ class TestPluckCommand:
139
139
  assert result.exit_code == 0, result.output
140
140
  # Should show class names (last chain) not fn names
141
141
  assert "ToolGroup" in result.output or "LazyToolGroup" in result.output
142
+
143
+ def test_pluck_mutation_blocked_by_default(self):
144
+ result = runner.invoke(cli, ["pluck", "squackit/cli.py",
145
+ "find", ".fn#nonexistent", "rename", "foo"])
146
+ assert result.exit_code != 0
147
+ assert "mutation" in result.output.lower()
148
+ assert "rename" in result.output
149
+
150
+ def test_pluck_mutation_allowed_with_write(self):
151
+ # Target nonexistent function so no actual mutation occurs,
152
+ # but block check should pass
153
+ result = runner.invoke(cli, ["pluck", "--write", "squackit/cli.py",
154
+ "find", ".fn#__definitely_not_a_function__",
155
+ "rename", "bar"])
156
+ # Should not be blocked by mutation check; may succeed or fail for
157
+ # other reasons, but the mutation message should not appear
158
+ assert "chain contains mutation operations" not in result.output
@@ -3,7 +3,7 @@
3
3
 
4
4
  def test_import_squackit():
5
5
  import squackit
6
- assert squackit.__version__ == "0.3.3"
6
+ assert squackit.__version__ == "0.4.0"
7
7
 
8
8
 
9
9
  def test_fledgling_available():
@@ -0,0 +1,282 @@
1
+ # tests/test_tools.py
2
+ """Tests for squackit.tools -- pluckit-backed tool executors."""
3
+
4
+ import pytest
5
+ from squackit.tools import (
6
+ PLUCKIT_TOOLS,
7
+ view_executor,
8
+ find_executor,
9
+ find_names_executor,
10
+ complexity_executor,
11
+ pluck_executor,
12
+ )
13
+
14
+
15
+ class TestViewExecutor:
16
+
17
+ def test_returns_view_object(self):
18
+ from pluckit.pluckins.viewer import View
19
+ result = view_executor(source="squackit/**/*.py", selector=".fn")
20
+ assert isinstance(result, View)
21
+
22
+ def test_view_has_markdown(self):
23
+ result = view_executor(source="squackit/cli.py", selector=".fn#cli")
24
+ assert "def cli" in result.markdown
25
+
26
+ def test_view_has_blocks(self):
27
+ result = view_executor(source="squackit/**/*.py", selector=".fn")
28
+ assert len(result.blocks) > 0
29
+
30
+
31
+ class TestFindExecutor:
32
+
33
+ def test_returns_relation(self):
34
+ result = find_executor(source="squackit/**/*.py", selector=".fn")
35
+ assert hasattr(result, "columns")
36
+ assert hasattr(result, "fetchall")
37
+
38
+ def test_relation_has_expected_columns(self):
39
+ result = find_executor(source="squackit/**/*.py", selector=".fn")
40
+ assert "name" in result.columns
41
+ assert "file_path" in result.columns
42
+ assert "start_line" in result.columns
43
+
44
+ def test_finds_known_function(self):
45
+ result = find_executor(source="squackit/cli.py", selector=".fn#cli")
46
+ rows = result.fetchall()
47
+ assert len(rows) >= 1
48
+
49
+
50
+ class TestFindNamesExecutor:
51
+
52
+ def test_returns_list_of_strings(self):
53
+ result = find_names_executor(source="squackit/**/*.py", selector=".fn")
54
+ assert isinstance(result, list)
55
+ assert all(isinstance(n, str) for n in result)
56
+
57
+ def test_finds_known_function(self):
58
+ result = find_names_executor(source="squackit/cli.py", selector=".fn")
59
+ assert "cli" in result
60
+
61
+
62
+ class TestComplexityExecutor:
63
+
64
+ def test_returns_relation(self):
65
+ result = complexity_executor(source="squackit/**/*.py", selector=".fn")
66
+ assert hasattr(result, "columns")
67
+ assert "complexity" in result.columns
68
+
69
+ def test_ordered_by_complexity_desc(self):
70
+ result = complexity_executor(source="squackit/**/*.py", selector=".fn")
71
+ rows = result.fetchall()
72
+ cx_idx = result.columns.index("complexity")
73
+ complexities = [r[cx_idx] for r in rows]
74
+ assert complexities == sorted(complexities, reverse=True)
75
+
76
+
77
+ class TestPluckitToolsList:
78
+
79
+ def test_five_tools_defined(self):
80
+ assert len(PLUCKIT_TOOLS) == 5
81
+
82
+ def test_all_have_executors(self):
83
+ for tp in PLUCKIT_TOOLS:
84
+ assert tp.executor is not None
85
+
86
+ def test_tool_names(self):
87
+ names = {tp.name for tp in PLUCKIT_TOOLS}
88
+ assert names == {"view", "find", "find_names", "complexity", "pluck"}
89
+
90
+ def test_selector_tools_require_source_and_selector(self):
91
+ for tp in PLUCKIT_TOOLS:
92
+ if tp.name == "pluck":
93
+ continue
94
+ assert "source" in tp.required
95
+ assert "selector" in tp.required
96
+
97
+ def test_pluck_requires_argv(self):
98
+ pluck = next(tp for tp in PLUCKIT_TOOLS if tp.name == "pluck")
99
+ assert "argv" in pluck.required
100
+
101
+
102
+ class TestPluckExecutor:
103
+
104
+ def test_returns_json_string(self):
105
+ import json
106
+ result = pluck_executor(argv="squackit/cli.py find .fn names")
107
+ parsed = json.loads(result)
108
+ assert "chain" in parsed
109
+ assert "type" in parsed
110
+ assert "data" in parsed
111
+
112
+ def test_find_names(self):
113
+ import json
114
+ result = pluck_executor(argv="squackit/cli.py find .fn names")
115
+ parsed = json.loads(result)
116
+ assert parsed["type"] == "names"
117
+ assert isinstance(parsed["data"], list)
118
+ assert "cli" in parsed["data"]
119
+
120
+ def test_count(self):
121
+ import json
122
+ result = pluck_executor(argv="squackit/cli.py find .fn count")
123
+ parsed = json.loads(result)
124
+ assert parsed["type"] == "count"
125
+ assert isinstance(parsed["data"], int)
126
+ assert parsed["data"] > 0
127
+
128
+ def test_view_with_plugin(self):
129
+ import json
130
+ result = pluck_executor(
131
+ argv="--plugin AstViewer squackit/cli.py find .fn#cli view"
132
+ )
133
+ parsed = json.loads(result)
134
+ assert parsed["type"] == "view"
135
+ # data is a dict with blocks
136
+ assert "blocks" in parsed["data"]
137
+
138
+ def test_empty_argv_returns_error(self):
139
+ import json
140
+ result = pluck_executor(argv="")
141
+ parsed = json.loads(result)
142
+ assert "error" in parsed
143
+
144
+
145
+ class TestPluckMutationSafety:
146
+
147
+ def test_mutation_blocked_by_default(self):
148
+ import json
149
+ # rename is a mutation op
150
+ result = pluck_executor(argv="squackit/cli.py find .fn#nonexistent rename foo")
151
+ parsed = json.loads(result)
152
+ assert "error" in parsed
153
+ assert "blocked" in parsed["error"]
154
+ assert "rename" in parsed["mutations"]
155
+
156
+ def test_mutation_blocked_reports_all_ops(self):
157
+ import json
158
+ result = pluck_executor(
159
+ argv="squackit/cli.py find .fn#x rename new wrap before after"
160
+ )
161
+ parsed = json.loads(result)
162
+ assert "error" in parsed
163
+ assert "rename" in parsed["mutations"]
164
+ assert "wrap" in parsed["mutations"]
165
+
166
+ def test_mutation_allowed_with_flag_string(self):
167
+ import json
168
+ # Target a nonexistent fn so no actual mutation happens, but the
169
+ # block check should pass and evaluation should proceed
170
+ result = pluck_executor(
171
+ argv="squackit/cli.py find .fn#__definitely_not_a_function__ rename bar",
172
+ allow_mutations="true",
173
+ )
174
+ parsed = json.loads(result)
175
+ # Should NOT be blocked — may have a different error or succeed
176
+ assert parsed.get("error", "").startswith("blocked") is False
177
+
178
+ def test_mutation_allowed_with_flag_bool(self):
179
+ import json
180
+ result = pluck_executor(
181
+ argv="squackit/cli.py find .fn#__definitely_not_a_function__ rename bar",
182
+ allow_mutations=True,
183
+ )
184
+ parsed = json.loads(result)
185
+ assert parsed.get("error", "").startswith("blocked") is False
186
+
187
+ def test_non_mutation_chain_runs_normally(self):
188
+ import json
189
+ # No mutation ops — should run regardless of allow_mutations
190
+ result = pluck_executor(argv="squackit/cli.py find .fn names")
191
+ parsed = json.loads(result)
192
+ assert "error" not in parsed
193
+ assert parsed["type"] == "names"
194
+
195
+
196
+ class TestChainMutationOps:
197
+
198
+ def test_detects_rename(self):
199
+ from pluckit import Chain
200
+ from squackit.tools import _chain_mutation_ops
201
+ chain = Chain.from_argv(["squackit/cli.py", "find", ".fn", "rename", "new"])
202
+ assert _chain_mutation_ops(chain) == ["rename"]
203
+
204
+ def test_detects_multiple(self):
205
+ from pluckit import Chain
206
+ from squackit.tools import _chain_mutation_ops
207
+ chain = Chain.from_argv([
208
+ "squackit/cli.py", "find", ".fn", "rename", "new",
209
+ "wrap", "before", "after",
210
+ ])
211
+ assert _chain_mutation_ops(chain) == ["rename", "wrap"]
212
+
213
+ def test_no_mutations_returns_empty(self):
214
+ from pluckit import Chain
215
+ from squackit.tools import _chain_mutation_ops
216
+ chain = Chain.from_argv(["squackit/cli.py", "find", ".fn", "names"])
217
+ assert _chain_mutation_ops(chain) == []
218
+
219
+
220
+ class TestCollectPluckinTools:
221
+ """collect_pluckin_tools walks registered pluckins and calls squackit_tools()."""
222
+
223
+ def test_empty_registry_returns_empty(self):
224
+ from pluckit import Plucker
225
+ from squackit.tools import collect_pluckin_tools
226
+ p = Plucker() # no plugins
227
+ assert collect_pluckin_tools(p) == []
228
+
229
+ def test_pluckin_without_squackit_tools_skipped(self):
230
+ from pluckit import Plucker
231
+ from pluckit.pluckins.viewer import AstViewer
232
+ from squackit.tools import collect_pluckin_tools
233
+ # AstViewer doesn't implement squackit_tools — should be skipped silently
234
+ p = Plucker(plugins=[AstViewer])
235
+ assert collect_pluckin_tools(p) == []
236
+
237
+ def test_pluckin_with_squackit_tools_collected(self):
238
+ from pluckit import Plucker
239
+ from pluckit.pluckins.base import Pluckin
240
+ from fledgling.tools import ToolInfo
241
+ from squackit.tool_config import ToolPresentation
242
+ from squackit.tools import collect_pluckin_tools
243
+
244
+ def exec_fn(*, x: str):
245
+ return [x]
246
+
247
+ sentinel = ToolPresentation(
248
+ info=ToolInfo(
249
+ macro_name="_test_sentinel",
250
+ params=["x"],
251
+ description="test tool",
252
+ required=["x"],
253
+ ),
254
+ executor=exec_fn,
255
+ )
256
+
257
+ class MyPluckin(Pluckin):
258
+ name = "MyPluckin"
259
+ methods = {}
260
+
261
+ def squackit_tools(self):
262
+ return [sentinel]
263
+
264
+ p = Plucker(plugins=[MyPluckin])
265
+ tools = collect_pluckin_tools(p)
266
+ assert sentinel in tools
267
+
268
+ def test_broken_squackit_tools_does_not_crash(self):
269
+ from pluckit import Plucker
270
+ from pluckit.pluckins.base import Pluckin
271
+ from squackit.tools import collect_pluckin_tools
272
+
273
+ class BrokenPluckin(Pluckin):
274
+ name = "BrokenPluckin"
275
+ methods = {}
276
+
277
+ def squackit_tools(self):
278
+ raise RuntimeError("intentional")
279
+
280
+ p = Plucker(plugins=[BrokenPluckin])
281
+ # Should return empty, not crash
282
+ assert collect_pluckin_tools(p) == []
@@ -1,142 +0,0 @@
1
- # tests/test_tools.py
2
- """Tests for squackit.tools -- pluckit-backed tool executors."""
3
-
4
- import pytest
5
- from squackit.tools import (
6
- PLUCKIT_TOOLS,
7
- view_executor,
8
- find_executor,
9
- find_names_executor,
10
- complexity_executor,
11
- pluck_executor,
12
- )
13
-
14
-
15
- class TestViewExecutor:
16
-
17
- def test_returns_view_object(self):
18
- from pluckit.plugins.viewer import View
19
- result = view_executor(source="squackit/**/*.py", selector=".fn")
20
- assert isinstance(result, View)
21
-
22
- def test_view_has_markdown(self):
23
- result = view_executor(source="squackit/cli.py", selector=".fn#cli")
24
- assert "def cli" in result.markdown
25
-
26
- def test_view_has_blocks(self):
27
- result = view_executor(source="squackit/**/*.py", selector=".fn")
28
- assert len(result.blocks) > 0
29
-
30
-
31
- class TestFindExecutor:
32
-
33
- def test_returns_relation(self):
34
- result = find_executor(source="squackit/**/*.py", selector=".fn")
35
- assert hasattr(result, "columns")
36
- assert hasattr(result, "fetchall")
37
-
38
- def test_relation_has_expected_columns(self):
39
- result = find_executor(source="squackit/**/*.py", selector=".fn")
40
- assert "name" in result.columns
41
- assert "file_path" in result.columns
42
- assert "start_line" in result.columns
43
-
44
- def test_finds_known_function(self):
45
- result = find_executor(source="squackit/cli.py", selector=".fn#cli")
46
- rows = result.fetchall()
47
- assert len(rows) >= 1
48
-
49
-
50
- class TestFindNamesExecutor:
51
-
52
- def test_returns_list_of_strings(self):
53
- result = find_names_executor(source="squackit/**/*.py", selector=".fn")
54
- assert isinstance(result, list)
55
- assert all(isinstance(n, str) for n in result)
56
-
57
- def test_finds_known_function(self):
58
- result = find_names_executor(source="squackit/cli.py", selector=".fn")
59
- assert "cli" in result
60
-
61
-
62
- class TestComplexityExecutor:
63
-
64
- def test_returns_relation(self):
65
- result = complexity_executor(source="squackit/**/*.py", selector=".fn")
66
- assert hasattr(result, "columns")
67
- assert "complexity" in result.columns
68
-
69
- def test_ordered_by_complexity_desc(self):
70
- result = complexity_executor(source="squackit/**/*.py", selector=".fn")
71
- rows = result.fetchall()
72
- cx_idx = result.columns.index("complexity")
73
- complexities = [r[cx_idx] for r in rows]
74
- assert complexities == sorted(complexities, reverse=True)
75
-
76
-
77
- class TestPluckitToolsList:
78
-
79
- def test_five_tools_defined(self):
80
- assert len(PLUCKIT_TOOLS) == 5
81
-
82
- def test_all_have_executors(self):
83
- for tp in PLUCKIT_TOOLS:
84
- assert tp.executor is not None
85
-
86
- def test_tool_names(self):
87
- names = {tp.name for tp in PLUCKIT_TOOLS}
88
- assert names == {"view", "find", "find_names", "complexity", "pluck"}
89
-
90
- def test_selector_tools_require_source_and_selector(self):
91
- for tp in PLUCKIT_TOOLS:
92
- if tp.name == "pluck":
93
- continue
94
- assert "source" in tp.required
95
- assert "selector" in tp.required
96
-
97
- def test_pluck_requires_argv(self):
98
- pluck = next(tp for tp in PLUCKIT_TOOLS if tp.name == "pluck")
99
- assert "argv" in pluck.required
100
-
101
-
102
- class TestPluckExecutor:
103
-
104
- def test_returns_json_string(self):
105
- import json
106
- result = pluck_executor(argv="squackit/cli.py find .fn names")
107
- parsed = json.loads(result)
108
- assert "chain" in parsed
109
- assert "type" in parsed
110
- assert "data" in parsed
111
-
112
- def test_find_names(self):
113
- import json
114
- result = pluck_executor(argv="squackit/cli.py find .fn names")
115
- parsed = json.loads(result)
116
- assert parsed["type"] == "names"
117
- assert isinstance(parsed["data"], list)
118
- assert "cli" in parsed["data"]
119
-
120
- def test_count(self):
121
- import json
122
- result = pluck_executor(argv="squackit/cli.py find .fn count")
123
- parsed = json.loads(result)
124
- assert parsed["type"] == "count"
125
- assert isinstance(parsed["data"], int)
126
- assert parsed["data"] > 0
127
-
128
- def test_view_with_plugin(self):
129
- import json
130
- result = pluck_executor(
131
- argv="--plugin AstViewer squackit/cli.py find .fn#cli view"
132
- )
133
- parsed = json.loads(result)
134
- assert parsed["type"] == "view"
135
- # data is a dict with blocks
136
- assert "blocks" in parsed["data"]
137
-
138
- def test_empty_argv_returns_error(self):
139
- import json
140
- result = pluck_executor(argv="")
141
- parsed = json.loads(result)
142
- assert "error" in parsed
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes